Compare commits

..

4 Commits

Author SHA1 Message Date
f9b9ce0994 fix 2026-01-11 09:53:28 +01:00
9c7f04d197 Merge branch 'feature/webApp' into develop 2026-01-11 09:02:37 +01:00
ab95d124bc fix 2026-01-11 09:02:21 +01:00
bdd3e30f14 webapp 2026-01-11 08:19:27 +01:00
24 changed files with 5055 additions and 91 deletions

View File

@@ -108,13 +108,14 @@ To change the default window size, edit `src-tauri/tauri.conf.json`:
[Add License Information Here]
## Release v0.1
## Release v0.2
Initial public preview (v0.1) — a minimal, working RadioPlayer experience:
Public beta (v0.2) — updates since v0.1:
- Custom CAF Receiver UI (HTML/CSS/JS) in `receiver/` with branded artwork and playback status.
- Plays LIVE stream: `https://live.radio1.si/Radio1MB` (contentType: `audio/mpeg`, streamType: `LIVE`).
- Desktop sidecar (`sidecar/index.js`) launches the Default Media Receiver and sends LOAD commands; launch flow now retries if the device reports `NOT_ALLOWED` by stopping existing sessions first.
- **Android build support:** Project includes Android build scripts and Gradle wrappers. See [scripts/build-android.sh](scripts/build-android.sh) and [build-android.ps1](build-android.ps1). Prebuilt native helper binaries are available in `src-tauri/binaries/` for convenience.
- **Web receiver & webapp:** The `receiver/` folder contains a Custom CAF Receiver UI (HTML/CSS/JS) and the `webapp/` folder provides a standalone web distribution for hosting the app in browsers or PWAs.
- **Sidecar improvements:** `sidecar/index.js` now retries launches when devices return `NOT_ALLOWED` by attempting to stop existing sessions before retrying. Check sidecar logs for `Launch NOT_ALLOWED` messages and retry attempts.
- **LIVE stream:** The app continues to support the LIVE stream `https://live.radio1.si/Radio1MB` (contentType: `audio/mpeg`, streamType: `LIVE`).
Included receiver files:
@@ -140,6 +141,6 @@ npx http-server receiver -p 8443 -S -C localhost.pem -K localhost-key.pem
Sidecar / troubleshoot
- If a Cast launch fails with `NOT_ALLOWED`, the sidecar will now attempt to stop any existing sessions on the device and retry the launch (best-effort). Check sidecar logs for `Launch NOT_ALLOWED` and subsequent retry attempts.
- If a Cast launch fails with `NOT_ALLOWED`, the sidecar will attempt to stop any existing sessions on the device and retry the launch (best-effort). Check sidecar logs for `Launch NOT_ALLOWED` and subsequent retry attempts.
- Note: the sidecar uses `castv2-client` (not the official Google sender SDK). Group/stereo behavior may vary across device types — for full sender capabilities consider adding an official sender implementation.

316
TECHNICAL_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,316 @@
# RadioPlayer — Technical Documentation (Tauri + Desktop)
This document describes the desktop (Tauri) application architecture, build pipeline, backend commands, and how the UI maps to that backend.
## High-level architecture
- **Frontend (WebView)**: Vanilla HTML/CSS/JS in [src/index.html](src/index.html), [src/main.js](src/main.js), [src/styles.css](src/styles.css)
- **Tauri host (Rust)**: Command layer + device discovery in [src-tauri/src/lib.rs](src-tauri/src/lib.rs)
- **Cast sidecar (Node executable)**: Google Cast control via `castv2-client` in [sidecar/index.js](sidecar/index.js)
- **Packaging utilities**:
- Sidecar binary copy/rename step: [tools/copy-binaries.js](tools/copy-binaries.js)
- Windows EXE icon patch: [tools/post-build-rcedit.js](tools/post-build-rcedit.js)
Data flow:
1. UI actions call JS functions in `main.js`.
2. When in **Cast mode**, JS calls Tauri commands via `window.__TAURI__.core.invoke()`.
3. The Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`.
4. On `cast_play/stop/volume`, Rust spawns (or reuses) a **sidecar process**, then sends newline-delimited JSON commands to the sidecar stdin.
## Running and building
### Prerequisites
- Node.js (project uses ESM at the root; see [package.json](package.json))
- Rust toolchain (via rustup)
- Platform build tools (Windows: Visual Studio C++ Build Tools)
- Tauri prerequisites (WebView2 runtime on Windows)
### Dev
From repo root:
- `npm install`
- `npm run dev`
This runs `tauri dev` (see [package.json](package.json)).
### Production build (Windows MSI/NSIS, etc.)
From repo root:
- `npm run build`
What it does (see [package.json](package.json)):
1. `node tools/copy-binaries.js` — ensures the expected bundled binary name exists.
2. `tauri build` — builds the Rust host and generates platform bundles.
3. `node tools/post-build-rcedit.js` — patches the Windows EXE icon using the locally installed `rcedit` binary.
Artifacts typically land under:
- `src-tauri/target/release/bundle/`
### Building the sidecar
The sidecar is built separately using `pkg` (see [sidecar/package.json](sidecar/package.json)):
- `cd sidecar`
- `npm install`
- `npm run build`
This outputs:
- `src-tauri/binaries/radiocast-sidecar-x86_64-pc-windows-msvc.exe`
## Tauri configuration
### App config
Defined in [src-tauri/tauri.conf.json](src-tauri/tauri.conf.json):
- **build.frontendDist**: `../src`
- The desktop app serves the static files in `src/`.
- **window**:
- `width: 360`, `height: 720`, `resizable: false`
- `decorations: false`, `transparent: true` (frameless / custom UI)
- **security.csp**: `null` (CSP disabled)
- **bundle.targets**: `"all"`
- **bundle.externalBin**: includes external binaries shipped with the bundle.
### Capabilities and permissions
Defined in [src-tauri/capabilities/default.json](src-tauri/capabilities/default.json):
- `core:default`
- `core:window:allow-close` (allows JS to call window close)
- `opener:default`
- `shell:default` (required for spawning the sidecar)
## Rust backend (Tauri commands)
All commands are in [src-tauri/src/lib.rs](src-tauri/src/lib.rs) and registered via `invoke_handler`.
### Shared state
- `AppState.known_devices: HashMap<String, String>`
- maps **device name****IP string**
- `SidecarState.child: Option<CommandChild>`
- stores a single long-lived sidecar child process
### mDNS discovery
In `.setup()` the backend spawns a thread that browses:
- `_googlecast._tcp.local.`
When a device is resolved:
- Name is taken from the `fn` TXT record if present, otherwise `fullname`.
- First IPv4 address is preferred.
- New devices are inserted into `known_devices` and logged.
### Commands
#### `list_cast_devices() -> Result<Vec<String>, String>`
- Returns the sorted list of discovered Cast device names.
- Used by the UI when opening the Cast picker overlay.
#### `cast_play(device_name: String, url: String) -> Result<(), String>`
- Resolves `device_name``ip` from `known_devices`.
- Spawns the sidecar if it doesnt exist yet:
- `app.shell().sidecar("radiocast-sidecar")`
- Sidecar stdout/stderr are forwarded to the Rust process logs.
- Writes a JSON line to the sidecar stdin:
```json
{ "command": "play", "args": { "ip": "<ip>", "url": "<streamUrl>" } }
```
#### `cast_stop(device_name: String) -> Result<(), String>`
- If the sidecar process exists, writes:
```json
{ "command": "stop", "args": {} }
```
#### `cast_set_volume(device_name: String, volume: f32) -> Result<(), String>`
- If the sidecar process exists, writes:
```json
{ "command": "volume", "args": { "level": 0.0 } }
```
Notes:
- `volume` is passed from the UI in the range `[0, 1]`.
#### `fetch_url(url: String) -> Result<String, String>`
- Performs a server-side HTTP GET using `reqwest`.
- Returns response body as text.
- Used by the UI to bypass browser CORS limitations when calling 3rd-party endpoints.
## Sidecar protocol and behavior
Implementation: [sidecar/index.js](sidecar/index.js)
### Input protocol (stdin)
The sidecar reads **newline-delimited JSON objects**:
- `{"command":"play","args":{"ip":"...","url":"..."}}`
- `{"command":"stop","args":{}}`
- `{"command":"volume","args":{"level":0.5}}`
### Output protocol (stdout/stderr)
Logs are JSON objects:
- `{"type":"log","message":"..."}` to stdout
- `{"type":"error","message":"..."}` to stderr
### Cast launch logic
- Connects to the device IP.
- Reads existing sessions via `getSessions()`.
- If Default Media Receiver (`appId === "CC1AD845"`) exists, tries to join.
- If other sessions exist, attempts to stop them to avoid `NOT_ALLOWED`.
- On `NOT_ALLOWED` launch, retries once after stopping sessions (best-effort).
## Frontend behavior
### Station data model
Stations are loaded from [src/stations.json](src/stations.json) and normalized in [src/main.js](src/main.js) into:
```js
{ id, name, url, logo, enabled, raw }
```
Normalization rules (important for `stations.json` format compatibility):
- `name`: `title || id || name || "Unknown"`
- `url`: `liveAudio || liveVideo || liveStream || url || ""`
- `logo`: `logo || poster || ""`
- Stations with `enabled === false` or without a URL are filtered out.
User-defined stations are stored in `localStorage` under `userStations` and appended after file stations.
The last selected station is stored under `localStorage.lastStationId`.
### Playback modes
State is tracked in JS:
- `currentMode`: `"local"` or `"cast"`
- `currentCastDevice`: string or `null`
- `isPlaying`: boolean
#### Local mode
- Uses `new Audio()` and sets `audio.src = station.url`.
#### Cast mode
- Uses backend invokes: `cast_play`, `cast_stop`, `cast_set_volume`.
### Current song (“Now Playing”) polling
- For the currently selected station only, the app polls a station endpoint every 10s.
- It prefers `raw.currentSong`, otherwise uses `raw.lastSongs`.
- Remote URLs are fetched via the Tauri backend `fetch_url` to bypass CORS.
- If the provider returns timing fields (`playTimeStart*`, `playTimeLength*`), the UI schedules a single refresh near song end.
### Overlays
The element [src/index.html](src/index.html) `#cast-overlay` is reused for two different overlays:
- Cast device picker (`openCastOverlay()`)
- Station grid chooser (`openStationsOverlay()`)
The content is switched by:
- Toggling the `stations-grid` class on `#device-list`
- Replacing `#device-list` contents dynamically
## UI controls (button-by-button)
All UI IDs below are in [src/index.html](src/index.html) and are wired in [src/main.js](src/main.js).
### Window / header
- `#close-btn`
- Calls `getCurrentWindow().close()` (requires `core:window:allow-close`).
- `#cast-toggle-btn`
- Opens the Cast overlay and lists discovered devices (`invoke('list_cast_devices')`).
- `#edit-stations-btn`
- Opens the Stations Editor overlay (user stations stored in `localStorage.userStations`).
Note:
- `#cast-toggle-btn` and `#edit-stations-btn` appear twice in the HTML header. Duplicate IDs are invalid HTML and only the first element returned by `getElementById()` will be wired.
### Coverflow (station carousel inside artwork)
- `#artwork-prev`
- Selects previous station via `setStationByIndex()`.
- `#artwork-next`
- Selects next station via `setStationByIndex()`.
- `#artwork-coverflow` (drag/wheel area)
- Pointer drag changes station when movement exceeds a threshold.
- Wheel scroll changes station with a short debounce.
- Coverflow card click
- Selects that station.
- Coverflow card double-click (on the selected station)
- Opens the station grid overlay.
### Transport controls
- `#play-btn`
- Toggles play/stop (`togglePlay()`):
- Local mode: `audio.play()` / `audio.pause()`.
- Cast mode: `invoke('cast_play')` / `invoke('cast_stop')`.
- `#prev-btn`
- Previous station (`playPrev()``setStationByIndex()`).
- `#next-btn`
- Next station (`playNext()``setStationByIndex()`).
### Volume
- `#volume-slider`
- Local: sets `audio.volume`.
- Cast: `invoke('cast_set_volume')`.
- Persists `localStorage.volume`.
- `#mute-btn`
- Present in the UI but currently not wired to a handler in `main.js`.
### Cast overlay
- `#close-overlay`
- Closes the overlay (`closeCastOverlay()`).
### Stations editor overlay
- `#editor-close-btn`
- Closes the editor overlay.
- `#add-station-form` submit
- Adds/updates a station in `localStorage.userStations`.
- Triggers a full station reload (`loadStations()`).
## Service worker / PWA pieces
- Service worker file: [src/sw.js](src/sw.js)
- Caches core app assets for offline-ish behavior.
- Web manifest: [src/manifest.json](src/manifest.json)
- Name/icons/theme for installable PWA (primarily relevant for the web build; harmless in Tauri).
## Known sharp edges / notes
- **Duplicate IDs in HTML header**: only one of the duplicates will receive JS event listeners.
- **Sidecar bundling name**: the build pipeline copies `radiocast-sidecar-...` to `RadioPlayer-...` (see [tools/copy-binaries.js](tools/copy-binaries.js)); ensure the bundled binary name matches what `shell.sidecar("radiocast-sidecar")` expects for your target.

View File

@@ -18,13 +18,6 @@
cursor: default;
}
/* Show pointer cursor for interactive / clickable elements (override global default) */
a, a[href], button, input[type="button"], input[type="submit"],
[role="button"], [onclick], .clickable, .icon-btn, .control-btn, label[for],
.station-item, [tabindex]:not([tabindex="-1"]) {
cursor: pointer !important;
}
/* Hide Scrollbars */
::-webkit-scrollbar {
display: none;

1453
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"npx": "^3.0.0",
"rcedit": "^1.1.2"
}
}

View File

@@ -9,6 +9,9 @@
"version": "1.0.0",
"dependencies": {
"castv2-client": "^1.2.0"
},
"bin": {
"radiocast-sidecar": "index.js"
}
},
"node_modules/@protobufjs/aspromise": {

View File

@@ -6,6 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RadioPlayer</title>
<link rel="stylesheet" href="styles.css">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#1f1f2e">
<link rel="apple-touch-icon" href="assets/favicon_io/apple-touch-icon.png">
<script src="main.js" defer type="module"></script>
</head>
@@ -93,8 +96,12 @@
<span class="blob b10"></span>
</div>
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
<span class="station-logo-text">1</span>
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station"></button>
<div id="artwork-coverflow-stage" class="artwork-coverflow-stage" role="list" aria-label="Station icons"></div>
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station"></button>
</div>
</div>
</div>
</section>

View File

@@ -28,8 +28,9 @@ const castBtn = document.getElementById('cast-toggle-btn');
const castOverlay = document.getElementById('cast-overlay');
const closeOverlayBtn = document.getElementById('close-overlay');
const deviceListEl = document.getElementById('device-list');
const logoTextEl = document.querySelector('.station-logo-text');
const logoImgEl = document.getElementById('station-logo-img');
const coverflowStageEl = document.getElementById('artwork-coverflow-stage');
const coverflowPrevBtn = document.getElementById('artwork-prev');
const coverflowNextBtn = document.getElementById('artwork-next');
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
// Global error handlers to avoid silent white screen and show errors in UI
window.addEventListener('error', (ev) => {
@@ -106,7 +107,7 @@ async function loadStations() {
try {
// stop any existing pollers before reloading stations
stopCurrentSongPollers();
const resp = await fetch('stations.json');
const resp = await fetch('/stations.json');
const raw = await resp.json();
// Normalize station objects so the rest of the app can rely on `name` and `url`.
@@ -152,6 +153,11 @@ async function loadStations() {
// Append user stations after file stations
stations = stations.concat(userNormalized);
// Debug: report how many stations we have after loading
try {
console.debug('loadStations: loaded stations count:', stations.length);
} catch (e) {}
if (stations.length > 0) {
// Try to restore last selected station by id
const lastId = getLastStationId();
@@ -163,7 +169,9 @@ async function loadStations() {
currentIndex = 0;
}
console.debug('loadStations: loading station index', currentIndex);
loadStation(currentIndex);
renderCoverflow();
// start polling for currentSong endpoints (if any)
startCurrentSongPollers();
}
@@ -173,6 +181,179 @@ async function loadStations() {
}
}
// --- Coverflow UI (3D-ish station cards like your reference image) ---
let coverflowPointerId = null;
let coverflowStartX = 0;
let coverflowLastX = 0;
let coverflowAccum = 0;
let coverflowMoved = false;
let coverflowWheelLock = false;
function renderCoverflow() {
try {
if (!coverflowStageEl) return;
coverflowStageEl.innerHTML = '';
stations.forEach((s, idx) => {
const item = document.createElement('div');
item.className = 'coverflow-item';
item.dataset.idx = String(idx);
const logoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
if (logoUrl) {
const img = document.createElement('img');
img.alt = `${s.name} logo`;
img.src = logoUrl;
img.addEventListener('error', () => {
item.innerHTML = '';
item.classList.add('fallback');
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
});
item.appendChild(img);
} else {
item.classList.add('fallback');
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
}
// Click a card: if it's not selected, select it.
// Double-click the selected card to open the full station grid.
item.addEventListener('click', async (ev) => {
if (coverflowMoved) return;
const idxClicked = Number(item.dataset.idx);
if (idxClicked !== currentIndex) {
await setStationByIndex(idxClicked);
}
});
item.addEventListener('dblclick', (ev) => {
const idxClicked = Number(item.dataset.idx);
if (idxClicked === currentIndex) openStationsOverlay();
});
coverflowStageEl.appendChild(item);
});
wireCoverflowInteractions();
updateCoverflowTransforms();
} catch (e) {
console.debug('renderCoverflow failed', e);
}
}
function wireCoverflowInteractions() {
try {
const host = document.getElementById('artwork-coverflow');
if (!host) return;
// Buttons
if (coverflowPrevBtn) coverflowPrevBtn.onclick = () => setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
if (coverflowNextBtn) coverflowNextBtn.onclick = () => setStationByIndex((currentIndex + 1) % stations.length);
// Pointer drag (mouse/touch)
host.onpointerdown = (ev) => {
if (!stations || stations.length <= 1) return;
coverflowPointerId = ev.pointerId;
coverflowStartX = ev.clientX;
coverflowLastX = ev.clientX;
coverflowAccum = 0;
coverflowMoved = false;
try { host.setPointerCapture(ev.pointerId); } catch (e) {}
};
host.onpointermove = (ev) => {
if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return;
const dx = ev.clientX - coverflowLastX;
coverflowLastX = ev.clientX;
if (Math.abs(ev.clientX - coverflowStartX) > 6) coverflowMoved = true;
// Accumulate movement; change station when threshold passed.
coverflowAccum += dx;
const threshold = 36;
if (coverflowAccum >= threshold) {
coverflowAccum = 0;
setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
} else if (coverflowAccum <= -threshold) {
coverflowAccum = 0;
setStationByIndex((currentIndex + 1) % stations.length);
}
};
host.onpointerup = (ev) => {
if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return;
coverflowPointerId = null;
// reset moved flag after click would have fired
setTimeout(() => { coverflowMoved = false; }, 0);
try { host.releasePointerCapture(ev.pointerId); } catch (e) {}
};
host.onpointercancel = (ev) => {
coverflowPointerId = null;
coverflowMoved = false;
};
// Wheel: next/prev with debounce
host.onwheel = (ev) => {
if (!stations || stations.length <= 1) return;
if (coverflowWheelLock) return;
const delta = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
if (Math.abs(delta) < 6) return;
ev.preventDefault();
coverflowWheelLock = true;
if (delta > 0) setStationByIndex((currentIndex + 1) % stations.length);
else setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
setTimeout(() => { coverflowWheelLock = false; }, 160);
};
} catch (e) {
console.debug('wireCoverflowInteractions failed', e);
}
}
function updateCoverflowTransforms() {
try {
if (!coverflowStageEl) return;
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
const maxVisible = 3;
items.forEach((el) => {
const idx = Number(el.dataset.idx);
const offset = idx - currentIndex;
if (Math.abs(offset) > maxVisible) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
el.style.transform = 'translate(-50%, -50%) scale(0.6)';
return;
}
const abs = Math.abs(offset);
const dir = offset === 0 ? 0 : (offset > 0 ? 1 : -1);
const translateX = dir * (abs * 78);
const translateZ = -abs * 70;
const rotateY = dir * (-28 * abs);
const scale = 1 - abs * 0.12;
const opacity = 1 - abs * 0.18;
const zIndex = 100 - abs;
el.style.opacity = String(opacity);
el.style.zIndex = String(zIndex);
el.style.pointerEvents = 'auto';
el.style.transform = `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) scale(${scale})`;
if (offset === 0) el.classList.add('selected');
else el.classList.remove('selected');
});
} catch (e) {
console.debug('updateCoverflowTransforms failed', e);
}
}
async function setStationByIndex(idx) {
if (idx < 0 || idx >= stations.length) return;
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = idx;
saveLastStationId(stations[currentIndex].id);
loadStation(currentIndex);
updateCoverflowTransforms();
if (wasPlaying) await play();
}
// --- Current Song Polling ---
const currentSongPollers = new Map(); // stationId -> { intervalId, timeoutId }
@@ -414,10 +595,9 @@ function updateNowPlayingUI() {
if (!station) return;
if (nowPlayingEl && nowArtistEl && nowTitleEl) {
// Show now-playing if we have either an artist or a title (some stations only provide title)
if (station.currentSongInfo && (station.currentSongInfo.artist || station.currentSongInfo.title)) {
nowArtistEl.textContent = station.currentSongInfo.artist || '';
nowTitleEl.textContent = station.currentSongInfo.title || '';
if (station.currentSongInfo && station.currentSongInfo.artist && station.currentSongInfo.title) {
nowArtistEl.textContent = station.currentSongInfo.artist;
nowTitleEl.textContent = station.currentSongInfo.title;
nowPlayingEl.classList.remove('hidden');
} else {
nowArtistEl.textContent = '';
@@ -623,8 +803,6 @@ function ensureArtworkPointerFallback() {
// Quick inline style fallback (helps when CSS is overridden)
try { ap.style.cursor = 'pointer'; } catch (e) {}
try { if (logoImgEl) logoImgEl.style.cursor = 'pointer'; } catch (e) {}
try { if (logoTextEl) logoTextEl.style.cursor = 'pointer'; } catch (e) {}
let active = false;
const onMove = (ev) => {
@@ -663,33 +841,8 @@ function loadStation(index) {
if (nowArtistEl) nowArtistEl.textContent = '';
if (nowTitleEl) nowTitleEl.textContent = '';
// Update Logo Text (First letter or number)
// Simple heuristic: if name has a number, use it, else first letter
// If station has a logo URL, show the image; otherwise show the text fallback
if (station.logo && station.logo.length > 0) {
// Verify the logo exists before showing it
checkImageExists(station.logo).then((exists) => {
if (exists) {
logoImgEl.src = station.logo;
logoImgEl.classList.remove('hidden');
logoTextEl.classList.add('hidden');
} else {
logoImgEl.src = '';
logoImgEl.classList.add('hidden');
logoTextEl.classList.remove('hidden');
}
});
} else {
// Fallback: show the full station name when no logo is provided
logoImgEl.src = '';
logoImgEl.classList.add('hidden');
try {
logoTextEl.textContent = (station.name || '').trim();
} catch (e) {
logoTextEl.textContent = '';
}
logoTextEl.classList.remove('hidden');
}
// Sync coverflow transforms (if present)
try { updateCoverflowTransforms(); } catch (e) {}
// When loading a station, ensure only this station's poller runs
try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); }
}
@@ -792,33 +945,15 @@ async function playNext() {
// If playing, stop first? Or seamless?
// For radio, seamless switch requires stop then play new URL
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = (currentIndex + 1) % stations.length;
loadStation(currentIndex);
// persist selection
saveLastStationId(stations[currentIndex].id);
if (wasPlaying) await play();
const nextIndex = (currentIndex + 1) % stations.length;
await setStationByIndex(nextIndex);
}
async function playPrev() {
if (stations.length === 0) return;
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
loadStation(currentIndex);
// persist selection
saveLastStationId(stations[currentIndex].id);
if (wasPlaying) await play();
const prevIndex = (currentIndex - 1 + stations.length) % stations.length;
await setStationByIndex(prevIndex);
}
function updateUI() {
@@ -922,6 +1057,15 @@ async function selectCastDevice(deviceName) {
window.addEventListener('DOMContentLoaded', init);
// Register Service Worker for PWA installation (non-disruptive)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js')
.then((reg) => console.log('ServiceWorker registered:', reg.scope))
.catch((err) => console.debug('ServiceWorker registration failed:', err));
});
}
// Open overlay and show list of stations (used by menu/hamburger)
async function openStationsOverlay() {
castOverlay.classList.remove('hidden');
@@ -984,10 +1128,7 @@ async function openStationsOverlay() {
currentMode = 'local';
currentCastDevice = null;
castBtn.style.color = 'var(--text-main)';
currentIndex = idx;
// Remember this selection
saveLastStationId(stations[idx].id);
loadStation(currentIndex);
await setStationByIndex(idx);
closeCastOverlay();
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
};

22
src/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "RadioPlayer",
"short_name": "Radio",
"description": "RadioPlayer — stream radio stations from the web",
"start_url": ".",
"scope": ".",
"display": "standalone",
"background_color": "#1f1f2e",
"theme_color": "#1f1f2e",
"icons": [
{
"src": "assets/favicon_io/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/favicon_io/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -399,15 +399,7 @@ body {
.artwork-placeholder:hover,
.station-logo-img,
.station-logo-text {
cursor: pointer !important;
pointer-events: auto;
}
/* Subtle hover affordance to make clickability clearer */
.artwork-placeholder:hover .station-logo-img,
.artwork-placeholder:hover .station-logo-text {
transform: scale(1.03);
transition: transform 160ms ease;
cursor: pointer;
}
/* Track Info */

48
src/sw.js Normal file
View File

@@ -0,0 +1,48 @@
const CACHE_NAME = 'radiocast-core-v1';
const CORE_ASSETS = [
'.',
'index.html',
'main.js',
'styles.css',
'stations.json',
'assets/favicon_io/android-chrome-192x192.png',
'assets/favicon_io/android-chrome-512x512.png',
'assets/favicon_io/apple-touch-icon.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
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;
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('index.html');
return new Response('', { status: 503, statusText: 'Service Unavailable' });
});
})
);
});

View File

@@ -19,11 +19,16 @@ if (!fs.existsSync(iconPath)) {
console.log('Patching EXE icon with rcedit...');
// Prefer local installed binary (node_modules/.bin) to avoid relying on npx.
// On Windows, npm typically creates a .cmd shim, which Node can execute.
// Prefer local installed binary to avoid relying on npx.
// Note: the `rcedit` npm package places the binary at node_modules/rcedit/bin/rcedit.exe
// and does not always create a node_modules/.bin shim.
const binDir = path.join(repoRoot, 'node_modules', '.bin');
const packageBinDir = path.join(repoRoot, 'node_modules', 'rcedit', 'bin');
const localCandidates = process.platform === 'win32'
? [
// Preferred: direct binary shipped by the package
path.join(packageBinDir, 'rcedit.exe'),
// Fallbacks: npm/yarn shim locations (if present)
path.join(binDir, 'rcedit.cmd'),
path.join(binDir, 'rcedit.exe'),
path.join(binDir, 'rcedit'),
@@ -37,9 +42,8 @@ if (localBin) {
cmd = localBin;
args = [exePath, '--set-icon', iconPath];
} else {
// Fallback to npx. Note: Node can't execute PowerShell shims (npx.ps1), so this may fail
// in environments that only provide .ps1 launchers.
cmd = 'npx';
// Last resort fallback to npx.
cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
args = ['rcedit', exePath, '--set-icon', iconPath];
}

19
webapp/README.md Normal file
View File

@@ -0,0 +1,19 @@
# RadioCast Webapp (Vite)
This folder contains a minimal Vite scaffold that loads the existing app code
from the workspace `src` folder. It is intentionally lightweight and keeps the
original project files unchanged.
Quick start:
```powershell
cd webapp
npm install
npm run dev
# open http://localhost:5173
```
Notes:
- The Vite config allows reading files from the parent workspace so the
existing `src/main.js` is reused.
- You can `npm run build` here to produce a static build in `webapp/dist`.

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

218
webapp/index.html Normal file
View File

@@ -0,0 +1,218 @@
<!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="stylesheet" href="styles.css">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#1f1f2e">
<link rel="apple-touch-icon" href="assets/favicon_io/apple-touch-icon.png">
<script type="module" src="/src/main.js"></script>
</head>
<body>
<div id="app" style="display:none"></div>
<div class="app-container">
<div class="bg-shape shape-1"></div>
<div class="bg-shape shape-2"></div>
<main class="glass-card">
<header data-tauri-drag-region>
<div class="header-top-row">
<div class="header-icons-left" aria-hidden="true">
<button id="edit-stations-btn" class="icon-btn" title="Edit Stations" aria-label="Edit Stations">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast" title="Cast">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
</svg>
</button>
</div>
<!-- status moved below station info -->
<div class="header-close">
<button id="close-btn" class="icon-btn close-btn" aria-label="Close" title="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="header-third-row">
<div class="header-icons">
<button id="edit-stations-btn" class="icon-btn" title="Edit Stations" aria-label="Edit Stations">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast" title="Cast">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
</svg>
</button>
</div>
</div>
</header>
<section class="artwork-section">
<div class="artwork-container">
<div class="artwork-placeholder">
<!-- Gooey SVG filter for fluid blob blending -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="goo">
<!-- increased blur for smoother, more transparent blending -->
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div class="logo-blobs" aria-hidden="true">
<span class="blob b1"></span>
<span class="blob b2"></span>
<span class="blob b3"></span>
<span class="blob b4"></span>
<span class="blob b5"></span>
<span class="blob b6"></span>
<span class="blob b7"></span>
<span class="blob b8"></span>
<span class="blob b9"></span>
<span class="blob b10"></span>
</div>
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
<span class="station-logo-text">1</span>
</div>
</div>
</section>
<section class="track-info">
<h2 id="station-name"></h2>
<div id="now-playing" class="now-playing hidden" aria-live="polite">
<div id="now-artist" class="now-artist" aria-hidden="false"></div>
<div id="now-title" class="now-title" aria-hidden="false"></div>
</div>
<p id="station-subtitle"></p>
<div id="status-indicator" class="status-indicator-wrap" aria-hidden="true">
<span class="status-dot"></span>
<span id="status-text"></span>
</div>
</section>
<!-- Visual Progress Bar (Live) -->
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill"></div>
<div class="progress-handle"></div>
</div>
</div>
<section class="controls-section">
<button id="prev-btn" class="control-btn secondary" aria-label="Previous Station">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<button id="play-btn" class="control-btn primary" aria-label="Play">
<div class="icon-container">
<!-- Play Icon -->
<svg id="icon-play" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
<!-- Stop/Pause Icon (Hidden by default) -->
<svg id="icon-stop" class="hidden" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h12v12H6z" />
</svg>
</div>
</button>
<button id="next-btn" class="control-btn secondary" aria-label="Next Station">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
</section>
<section class="volume-section">
<button id="mute-btn" class="icon-btn small">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</button>
<div class="slider-container">
<input type="range" id="volume-slider" min="0" max="100" value="50">
</div>
<span id="volume-value">50%</span>
</section>
<!-- Hidden Cast Overlay (Beautified) -->
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
<h2 id="deviceTitle">Choose</h2>
<ul id="device-list" class="device-list">
<!-- Render device items here -->
<li class="device">
<div class="device-main">Scanning...</div>
<div class="device-sub">Searching for speakers</div>
</li>
</ul>
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
</div>
</div>
<!-- Stations Editor Overlay -->
<div id="editor-overlay" class="overlay hidden" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="editorTitle">
<h2 id="editorTitle">Edit Stations</h2>
<ul id="editor-list" class="device-list"></ul>
<form id="add-station-form">
<div style="margin-bottom:8px;">
<input id="us_title" placeholder="Title" required style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
</div>
<div style="margin-bottom:8px;">
<input id="us_url" placeholder="Stream URL" required style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
</div>
<div style="margin-bottom:8px;">
<input id="us_logo" placeholder="Logo URL (optional)" style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
</div>
<div style="margin-bottom:12px;">
<input id="us_www" placeholder="Website (optional)" style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
</div>
<input type="hidden" id="us_id">
<input type="hidden" id="us_index">
<div style="display:flex;gap:8px;">
<button id="us_save_btn" class="btn cancel" type="submit" style="flex:1">Save</button>
<button id="editor-close-btn" class="btn" type="button" style="flex:0;background:#6b6bff">Close</button>
</div>
</form>
</div>
</div>
</main>
</div>
</body>
</html>

22
webapp/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "RadioPlayer",
"short_name": "Radio",
"description": "RadioPlayer — stream radio stations from the web",
"start_url": ".",
"scope": ".",
"display": "standalone",
"background_color": "#1f1f2e",
"theme_color": "#1f1f2e",
"icons": [
{
"src": "assets/favicon_io/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/favicon_io/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

942
webapp/package-lock.json generated Normal file
View File

@@ -0,0 +1,942 @@
{
"name": "radiocast-webapp",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "radiocast-webapp",
"version": "0.1.0",
"devDependencies": {
"vite": "^5.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

14
webapp/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "radiocast-webapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5174"
},
"devDependencies": {
"vite": "^5.0.0"
}
}

20
webapp/src/main.js Normal file
View File

@@ -0,0 +1,20 @@
// RadioCast webapp entry (web-only)
// Removed Tauri-specific shims so this file runs in a plain browser.
document.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('app');
if (!app) {
console.warn('No #app element found');
return;
}
app.innerHTML = `
<main style="font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; padding: 2rem;">
<h1>RadioCast (Web)</h1>
<p>Running as a plain web application (no Tauri).</p>
<div id="status">Status: Idle</div>
</main>
`;
console.log('RadioCast webapp started (web mode)');
});

800
webapp/stations.json Normal file
View File

@@ -0,0 +1,800 @@
[
{
"id": "Radio1",
"title": "Radio 1",
"slogan": "Več dobre glasbe",
"logo": "http://datacache.radio.si/api/radiostations/logo/radio1.svg",
"liveAudio": "http://live.radio1.si/Radio1",
"liveVideo": null,
"poster": "",
"lastSongs": "http://data.radio.si/api/lastsongsxml/radio1/json",
"epg": "http://spored.radio.si/api/now/radio1",
"defaultText": "www.radio1.si",
"www": "https://www.radio1.si",
"mountPoints": [
"Radio1",
"Radio1BK",
"Radio1CE",
"Radio1GOR",
"Radio1KOR",
"Radio1LI",
"Radio1MB",
"Radio1NM",
"Radio1OB",
"Radio1PO",
"Radio1PR",
"Radio1PRI",
"Radio1PT",
"Radio1RIB",
"Radio1VE",
"Radio1VR",
"Radio1SAV"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38651300300"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "http://m.radio1.si"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "http://www.youtube.com/user/radio1slovenia"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "http://facebook.com/RadioEna"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "http://www.instagram.com/radio1slo"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/radio1?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=50668",
"rpUid": "705167",
"dabUser": "radio1",
"dabPass": "sUbSGhmzdwKQT",
"dabDefaultImg": "http://media.radio.si/logo/dns/radio1/320x240.png",
"small": false
},
{
"id": "Aktual",
"title": "Radio Aktual",
"slogan": "Narejen za vaša ušesa",
"logo": "http://datacache.radio.si/api/radiostations/logo/aktual.svg",
"liveAudio": "http://live.radio.si/Aktual",
"liveVideo": "https://radio.serv.si/AktualTV/video.m3u8",
"poster": "https://cdn1.radio.si/900/screenaktual_90c0280a8.jpg",
"lastSongs": "http://data.radio.si/api/lastsongsxml/aktual/json",
"epg": null,
"defaultText": "",
"www": "https://radioaktual.si",
"mountPoints": [
"Aktual"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+386158801430"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://radioaktual.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "http://www.youtube.com/user/raktual?sub_confirmation=1"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/raktual"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radioaktual/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705160",
"dabUser": "aktual",
"dabPass": "GB31GZd5st0M",
"dabDefaultImg": "http://media.radio.si/logo/dns/aktual/RadioAktual_DAB.jpg",
"small": false
},
{
"id": "Veseljak",
"title": "Radio Veseljak",
"slogan": "Najboljša domača glasba",
"logo": "http://datacache.radio.si/api/radiostations/logo/veseljak.svg",
"liveAudio": "http://live.radio.si/Veseljak",
"liveVideo": "https://radio.serv.si/VeseljakGolicaTV/video.m3u8",
"poster": "https://cdn1.radio.si/900/screenveseljak_166218c26.jpg",
"lastSongs": "http://data.radio.si/api/lastsongsxml/veseljak/json",
"epg": null,
"defaultText": "www.veseljak.si",
"www": "https://veseljak.si/",
"mountPoints": [
"Veseljak",
"VeseljakPO"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38615880110"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://veseljak.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/c/VESELJAKNAJBOLJSADOMACAGLASBA"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/RadioVeseljak"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/veseljak.si/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705166",
"dabUser": "veseljak",
"dabPass": "sLRDCAX9j3k2",
"dabDefaultImg": "http://media.radio.si/logo/dns/veseljak/RadioVeseljak_DAB.jpg",
"small": false
},
{
"id": "Radio1Rock",
"title": "Radio 1 ROCK",
"slogan": "100% Rock",
"logo": "http://datacache.radio.si/api/radiostations/logo/radio1rock.svg",
"liveAudio": "http://live.radio.si/Radio1Rock",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/radio1rock/json",
"epg": "http://spored.radio.si/api/now/radio1rock",
"defaultText": "www.radio1rock.si",
"www": "https://radio1rock.si/",
"mountPoints": [
"Radio1Rock"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38683879300"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.radio1rock.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/R1Rock"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/R1rock.si/"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiobob?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61109",
"rpUid": "705162",
"dabUser": "radiobob",
"dabPass": "cjT24PpyVxit6",
"dabDefaultImg": "http://media.radio.si/logo/dns/radio1rock/320x240.png",
"small": false
},
{
"id": "Radio80",
"title": "Radio 1 80-a",
"slogan": "Samo hiti 80-ih",
"logo": "http://datacache.radio.si/api/radiostations/logo/radio80.svg",
"liveAudio": "http://live.radio.si/Radio80",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/radio80/json",
"epg": "http://spored.radio.si/api/now/radio80",
"defaultText": "www.radio80.si",
"www": "https://radio80.si/",
"mountPoints": [
"Radio80"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38615008875"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://radio80.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/radio1slovenia"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/radioena"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/radio180-a?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=89760",
"rpUid": "705102",
"dabUser": "radio80",
"dabPass": "nc6da2LolcBXC",
"dabDefaultImg": "http://media.radio.si/logo/dns/radio80/320x240.png",
"small": false
},
{
"id": "Radio90",
"title": "Radio 1 90-a",
"slogan": "Samo hiti 90-ih",
"logo": "http://datacache.radio.si/api/radiostations/logo/radio90.svg",
"liveAudio": "http://live.radio.si/Radio90",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/radio90/json",
"epg": null,
"defaultText": "www.radio1.si",
"www": "https://radio1.si/",
"mountPoints": [
"Radio90"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38615008875"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.radio1.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/radio1slovenia"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/radioena"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705172",
"dabUser": "radio90",
"dabPass": "P2RyUrHcyq7M",
"dabDefaultImg": "http://media.radio.si/logo/dns/radio90/320x240.png",
"small": false
},
{
"id": "Toti",
"title": "Toti radio",
"slogan": "Toti hudi hiti",
"logo": "http://datacache.radio.si/api/radiostations/logo/toti.svg",
"liveAudio": "http://live.radio.si/Toti",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/toti/json",
"epg": "http://spored.radio.si/api/now/toti",
"defaultText": "www.totiradio.si",
"www": "https://totiradio.si/",
"mountPoints": [
"Maxi",
"Toti"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38651220220"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://totiradio.si/"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/totiradio?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=91414",
"rpUid": "705108",
"dabUser": "toti",
"dabPass": "wmAos05tECsmf",
"dabDefaultImg": "http://media.radio.si/logo/dns/toti/320x240.png",
"small": false
},
{
"id": "Antena",
"title": "Radio Antena",
"slogan": "Največ hitov, najmanj govora",
"logo": "http://datacache.radio.si/api/radiostations/logo/antena.svg",
"liveAudio": "http://live.radio.si/Antena",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/antena/json",
"epg": "http://spored.radio.si/api/now/antena",
"defaultText": "www.radioantena.si",
"www": "https://radioantena.si/",
"mountPoints": [
"Antena"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38612425630 "
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://radioantena.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/user/radioantenaslo"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/HitradioAntena"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radioantena.si/"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/radioantena?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=37864",
"rpUid": "705161",
"dabUser": "radioantena",
"dabPass": "nGkMhFk77jnBQ",
"dabDefaultImg": "http://media.radio.si/logo/dns/antena/320x240.png",
"small": false
},
{
"id": "BestFM",
"title": "BestFM",
"slogan": "Muska, muska, muska",
"logo": "http://datacache.radio.si/api/radiostations/logo/bestfm.svg",
"liveAudio": "http://live.radio.si/BestFM",
"liveVideo": "https://radio.serv.si/BestTV/video.m3u8",
"poster": "https://cdn1.radio.si/900/screenbest_6559e3ac8.jpg",
"lastSongs": "http://data.radio.si/api/lastsongsxml/bestfm/json",
"epg": null,
"defaultText": "www.bestfm.si",
"www": "https://bestfm.si/",
"mountPoints": [
"BestFM"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38673372030"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://bestfm.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/profile.php?id=100086776586975"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/bestfm.si/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705115",
"dabUser": "bestfm",
"dabPass": "momo911x",
"dabDefaultImg": "http://media.radio.si/logo/dns/bestfm/BestFM_DAB.jpg",
"small": false
},
{
"id": "Krka",
"title": "Radio Krka",
"slogan": "Dolenjska v srcu",
"logo": "http://datacache.radio.si/api/radiostations/logo/krka.svg",
"liveAudio": "http://live.radio.si/Krka",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/krka/json",
"epg": "",
"defaultText": "www.radiokrka.si",
"www": "https://radiokrka.si/",
"mountPoints": [
"Krka"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38673372030"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://radiokrka.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/user/radiokrka"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/radiokrka"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radiokrka/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705120",
"dabUser": "krka",
"dabPass": "qBi6z!um2Gm",
"dabDefaultImg": "http://media.radio.si/logo/dns/krka/RadioKrka_DAB.jpg",
"small": false
},
{
"id": "Klasik",
"title": "Klasik radio",
"slogan": "Glasba, ki vas sprosti",
"logo": "https://data.radio.si/api/radiostations/logo/klasik.svg",
"liveAudio": "http://live.radio.si/Klasik",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/klasik/json",
"epg": "",
"defaultText": "www.klasikradio.si",
"www": "https://www.klasikradio.si/",
"mountPoints": [
"Klasik"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38612425630"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.klasikradio.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/profile.php?id=100064736766638"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705176",
"dabUser": "klasik",
"dabPass": "mQTpTR9XEbiF",
"dabDefaultImg": "http://media.radio.si/logo/dns/klasik/320x240.png",
"small": false
},
{
"id": "Maxi",
"title": "Toti Maxi",
"slogan": "Sama dobra glasba",
"logo": "https://data.radio.si/api/radiostations/logo/maxi.svg",
"liveAudio": "http://live.radio.si/Maxi",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/toti/json",
"epg": "",
"defaultText": "www.totimaxi.si",
"www": "https://www.radiomaxi.si/",
"mountPoints": [
"Maxi"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38631628444"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.radiomaxi.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/profile.php?id=100064736766638"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/radiosalomon"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radiosalomon/"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/totiradio?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=37998",
"rpUid": "705109",
"dabUser": "salomon",
"dabPass": "a1bfadd8b8ut",
"dabDefaultImg": "http://media.radio.si/logo/dns/salomon/RadioSalomon_DAB.jpg",
"small": false
},
{
"id": "Salomon",
"title": "Radio Salomon",
"slogan": "Izbrana urbana glasba",
"logo": "http://datacache.radio.si/api/radiostations/logo/salomon.svg",
"liveAudio": "http://live.radio.si/Salomon",
"liveVideo": null,
"poster": null,
"lastSongs": "http://data.radio.si/api/lastsongsxml/salomon/json",
"epg": "",
"defaultText": "www.radiosalomon.si",
"www": "https://radiosalomon.si/",
"mountPoints": [
"Salomon"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+386015880111"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://radiosalomon.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/channel/UCd7OpUbSIoZarJgwFf4aIxw"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/RadioSalomon"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radiosalomon/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705116",
"dabUser": "salomon",
"dabPass": "a1bfadd8b8ut",
"dabDefaultImg": "http://media.radio.si/logo/dns/salomon/RadioSalomon_DAB.jpg",
"small": false
},
{
"id": "Ptuj",
"title": "Radio Ptuj",
"slogan": "Največje uspešnice vseh časov",
"logo": "https://data.radio.si/api/radiostations/logo/ptuj.svg",
"liveAudio": "http://live.radio.si/Ptuj",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/ptuj/json",
"epg": "",
"defaultText": "www.radio-ptuj.si",
"www": "https://www.radio-ptuj.si/",
"mountPoints": [
"Ptuj"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38627493420"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.radio-ptuj.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/@RadioPtuj"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/RadioPtuj"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radio_ptuj/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705119",
"dabUser": "ptuj",
"dabPass": "cwv4jXVKMYT",
"dabDefaultImg": "http://media.radio.si/logo/dns/ptuj/RadioPtuj_DAB.jpg",
"small": false
},
{
"id": "Fantasy",
"title": "Radio Fantasy",
"slogan": "Same dobre vibracije",
"logo": "https://data.radio.si/api/radiostations/logo/fantasy.svg",
"liveAudio": "http://live.radio.si/Fantasy",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/fantasy/json",
"epg": "http://spored.radio.si/api/now/robin",
"defaultText": "",
"www": "https://rfantasy.si/",
"mountPoints": [
"Fantasy"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38634903921"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.rfantasy.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/c/RadioFantasyTv"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/RadioFantasySlo"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radiofantasyslo/"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiofantasy?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61118",
"rpUid": "",
"dabUser": "radiorobin",
"dabPass": "rt5mo9b9",
"dabDefaultImg": "http://media.radio.si/logo/dns/robin/320x240.png",
"small": false
},
{
"id": "Robin",
"title": "Radio Robin",
"slogan": "Brez tebe ni mene",
"logo": "https://data.radio.si/api/radiostations/logo/robin.svg",
"liveAudio": "http://live.radio.si/Robin",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/robin/json",
"epg": "http://spored.radio.si/api/now/robin",
"defaultText": "www.robin.si",
"www": "https://www.robin.si/",
"mountPoints": [
"Robin"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38653302822"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.robin.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/channel/UCACfPObotnJAnVXfCZNMlUg"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/Radio.Robin.goriski"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/radio_robin/"
}
],
"enabled": true,
"radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiorobin?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=37984",
"rpUid": "705103",
"dabUser": "radiorobin",
"dabPass": "rt5mo9b9",
"dabDefaultImg": "http://media.radio.si/logo/dns/robin/320x240.png",
"small": false
},
{
"id": "Koroski",
"title": "Koroški radio",
"slogan": "Ritem Koroške",
"logo": "https://data.radio.si/api/radiostations/logo/koroski.svg",
"liveAudio": "http://live.radio.si/Koroski",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/koroski/json",
"epg": "http://spored.radio.si/api/now/koroski",
"defaultText": "www.koroski-radio.si",
"www": "https://www.koroski-radio.si/",
"mountPoints": [
"Koroski"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38628841245"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://www.koroski-radio.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/channel/UCLwH6lX4glK4o1N77JkeaJw"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/KoroskiRadio"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/koroski_r/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705105",
"dabUser": "koroski",
"dabPass": "num87dhket",
"dabDefaultImg": "http://media.radio.si/logo/dns/koroski/320x240.png",
"small": true
},
{
"id": "VeseljakZlatiZvoki",
"title": "Veseljak Zlati zvoki",
"slogan": "Najvecja zakladnica slovenske domace glasbe",
"logo": "https://data.radio.si/api/radiostations/logo/veseljakzlatizvoki.svg",
"liveAudio": "http://live.radio.si/VeseljakZlatiZvoki",
"liveVideo": null,
"poster": null,
"lastSongs": "https://data.radio.si/api/lastsongsxml/veseljakzlatizvoki/json",
"epg": "",
"defaultText": "www.veseljak.si",
"www": "https://www.veseljak.si/",
"mountPoints": [
"VeseljakZlatiZvoki"
],
"social": [
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg",
"link": "tel:+38615880110"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg",
"link": "https://veseljak.si/"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg",
"link": "https://www.youtube.com/c/VESELJAKNAJBOLJSADOMACAGLASBA"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg",
"link": "https://www.facebook.com/RadioVeseljak"
},
{
"icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg",
"link": "https://www.instagram.com/veseljak.si/"
}
],
"enabled": true,
"radioApiIO": "",
"rpUid": "705175",
"dabUser": "zlatizvoki",
"dabPass": "4jeeUnjA4qYV",
"dabDefaultImg": "http://media.radio.si/logo/dns/veseljakzlatizvoki/RadioVeseljakZlatiZvoki_DAB.jpg",
"small": false
},
{
"id": "RockMB",
"title": "Rock Maribor",
"slogan": "100% Rock",
"logo": "https://data.radio.si/api/radiostations/logo/rockmb.svg",
"liveAudio": "http://live.radio.si/RockMB",
"liveVideo": null,

886
webapp/styles.css Normal file
View File

@@ -0,0 +1,886 @@
/* Copied from src/styles.css */
:root {
--bg-gradient: linear-gradient(135deg, #7b7fd8, #b57cf2);
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
--accent: #dfa6ff;
--accent-glow: rgba(223, 166, 255, 0.5);
--text-main: #ffffff;
--text-muted: rgba(255, 255, 255, 0.7);
--danger: #cf6679;
--success: #7dffb3;
--card-radius: 10px;
}
* {
box-sizing: border-box;
user-select: none;
-webkit-user-drag: none;
cursor: default;
}
/* Hide Scrollbars */
::-webkit-scrollbar {
display: none;
}
body {
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3, #7b7fd8);
.status-indicator-wrap {
display:flex;
align-items:center;
gap:10px;
justify-content:center;
margin-top:8px;
color:var(--text-main);
}
background-size: 400% 400%;
animation: gradientShift 12s ease-in-out infinite;
font-family: 'Segoe UI', system-ui, sans-serif;
color: var(--text-main);
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
25% {
background-position: 100% 50%;
}
50% {
background-position: 50% 100%;
}
75% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Background Blobs */
.bg-shape {
position: absolute;
border-radius: 50%;
filter: blur(60px);
z-index: 0;
opacity: 0.6;
animation: float 10s infinite alternate;
}
.shape-1 {
width: 300px;
height: 300px;
background: #5e60ce;
top: -50px;
left: -50px;
}
.shape-2 {
width: 250px;
height: 250px;
background: #ff6bf0;
bottom: -50px;
right: -50px;
animation-delay: -5s;
}
@keyframes float {
0% { transform: translate(0, 0); }
100% { transform: translate(30px, 30px); }
}
.app-container {
width: 100%;
height: 100%;
position: relative;
padding: 10px; /* Slight padding from window edges if desired, or 0 */
}
.glass-card {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(24px);
border-radius: var(--card-radius);
display: flex;
flex-direction: column;
padding: 24px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
/* Make whole card draggable for window movement; interactive children override with no-drag */
.glass-card {
-webkit-app-region: drag;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
-webkit-app-region: drag; /* Draggable area */
padding: 10px 14px 8px 14px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(60,84,255,0.14), rgba(123,127,216,0.10));
border: 1px solid rgba(120,130,255,0.12);
box-shadow: 0 10px 30px rgba(28,25,60,0.35), inset 0 1px 0 rgba(255,255,255,0.03);
backdrop-filter: blur(8px) saturate(120%);
position: relative;
z-index: 3;
}
.header-top {
display:flex;
justify-content:space-between;
align-items:center;
width:100%;
}
.header-top-row {
display:flex;
justify-content:space-between;
align-items:center;
width:100%;
}
.header-icons-left { flex: 0 0 auto; display:flex; align-items:center; gap:8px; padding-left:8px; }
.header-center-status { flex:1; display:flex; justify-content:center; align-items:center; }
.header-close { flex:0 0 auto; }
.header-second-row {
display:flex;
justify-content:center;
align-items:center;
width:100%;
margin-top:6px;
}
.status-indicator-wrap { display:flex; gap:8px; align-items:center; color:var(--text-main); }
.header-third-row { display:none; }
.header-left {
justify-content: flex-start;
flex: 0 0 auto;
}
.header-right {
justify-content: flex-end;
flex: 0 0 auto;
}
.app-title { text-align: center; }
.header-info {
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.app-title {
font-weight: 700;
font-size: 1.05rem;
color: var(--text-main);
letter-spacing: 0.4px;
}
.status-indicator {
font-size: 0.85rem;
color: var(--success);
margin-top: 0;
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 6px;
height: 6px;
background-color: var(--success);
border-radius: 50%;
box-shadow: 0 0 8px var(--success);
}
.icon-btn {
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.03);
color: var(--text-main);
padding: 8px;
cursor: pointer;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.12s ease, background 0.12s ease, box-shadow 0.12s ease;
-webkit-app-region: no-drag; /* Buttons clickable */
}
.icon-btn:hover {
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
transform: translateY(-3px);
box-shadow: 0 10px 24px rgba(0,0,0,0.2);
}
.header-buttons {
display: flex;
gap: 8px;
align-items: center;
-webkit-app-region: no-drag;
}
.close-btn:hover {
background: rgba(207, 102, 121, 0.3) !important;
color: var(--danger);
}
/* Artwork */
.artwork-section {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
.artwork-container {
width: 220px;
height: 220px;
border-radius: 24px;
padding: 6px; /* spacing for ring */
background: linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.00));
box-shadow: 0 12px 40px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(8px) saturate(120%);
position: relative;
}
.artwork-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4ea8de, #6930c3);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
box-shadow: inset 0 0 30px rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.04);
}
/* glossy inner rim for artwork */
.artwork-container::after {
content: '';
position: absolute;
inset: 6px; /* follows padding to create rim */
border-radius: 20px;
pointer-events: none;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05), inset 0 -20px 40px rgba(255,255,255,0.02);
mix-blend-mode: overlay;
}
/* Make artwork clickable and give subtle hover feedback */
.artwork-placeholder {
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.artwork-placeholder:hover {
box-shadow: 0 18px 40px rgba(255, 255, 0, 0.45), inset 0 0 28px rgba(255,255,255,0.02);
}
.artwork-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4ea8de, #6930c3);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
}
.station-logo-text {
font-size: 5rem;
font-weight: 800;
font-style: italic;
color: rgba(255,255,255,0.9);
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
position: relative;
z-index: 3;
}
.station-logo-img {
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
width: 100%;
height: 100%;
object-fit: contain;
display: block;
padding: 12px; /* inner spacing from rounded edges */
box-sizing: border-box;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
position: relative;
z-index: 3;
}
/* Logo blobs container sits behind logo but inside artwork placeholder */
.logo-blobs {
position: absolute;
inset: 0;
filter: url(#goo);
z-index: 1;
pointer-events: none;
}
.blob {
position: absolute;
border-radius: 50%;
/* more transparent overall */
opacity: 0.18;
/* slightly smaller blur for subtle definition */
filter: blur(6px);
}
.b1 { width: 110px; height: 110px; left: 8%; top: 20%; background: radial-gradient(circle at 30% 30%, #c77dff, #8b5cf6); animation: float1 6s ease-in-out infinite; }
.b2 { width: 85px; height: 85px; right: 6%; top: 10%; background: radial-gradient(circle at 30% 30%, #7bffd1, #7dffb3); animation: float2 5.5s ease-in-out infinite; }
.b3 { width: 95px; height: 95px; left: 20%; bottom: 12%; background: radial-gradient(circle at 20% 20%, #ffd07a, #ff6bf0); animation: float3 7s ease-in-out infinite; }
.b4 { width: 70px; height: 70px; right: 24%; bottom: 18%; background: radial-gradient(circle at 30% 30%, #6bd3ff, #4ea8de); animation: float4 6.5s ease-in-out infinite; }
.b5 { width: 50px; height: 50px; left: 46%; top: 36%; background: radial-gradient(circle at 40% 40%, #ffa6d6, #c77dff); animation: float5 8s ease-in-out infinite; }
/* Additional blobs */
.b6 { width: 75px; height: 75px; left: 12%; top: 48%; background: radial-gradient(circle at 30% 30%, #bde7ff, #6bd3ff); animation: float6 6.8s ease-in-out infinite; }
.b7 { width: 42px; height: 42px; right: 10%; top: 42%; background: radial-gradient(circle at 40% 40%, #ffd9b3, #ffd07a); animation: float7 7.2s ease-in-out infinite; }
.b8 { width: 70px; height: 70px; left: 34%; bottom: 8%; background: radial-gradient(circle at 30% 30%, #e3b6ff, #c77dff); animation: float8 6.4s ease-in-out infinite; }
.b9 { width: 36px; height: 36px; right: 34%; bottom: 6%; background: radial-gradient(circle at 30% 30%, #9ef7d3, #7bffd1); animation: float9 8.4s ease-in-out infinite; }
.b10 { width: 30px; height: 30px; left: 52%; bottom: 28%; background: radial-gradient(circle at 30% 30%, #ffd0f0, #ffa6d6); animation: float10 5.8s ease-in-out infinite; }
@keyframes float1 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(8px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float2 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float3 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(8px) translateX(-10px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float4 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float5 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-12px) translateX(4px) scale(1.07); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float6 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-8px) translateX(6px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float7 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float8 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float9 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(-4px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float10 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(2px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
/* Slightly darken backdrop gradient so blobs read better */
.artwork-placeholder::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12));
z-index: 0;
}
/* Make artwork/logo clickable: show pointer cursor */
.artwork-placeholder,
.artwork-placeholder:hover,
.station-logo-img,
.station-logo-text {
cursor: pointer !important;
pointer-events: auto;
}
/* Subtle hover affordance to make clickability clearer */
.artwork-placeholder:hover .station-logo-img,
.artwork-placeholder:hover .station-logo-text {
transform: scale(1.03);
transition: transform 160ms ease;
}
/* Track Info */
.track-info {
text-align: center;
margin-bottom: 20px;
/* Reserve fixed space for station name, artist and title to avoid layout jumps */
min-height: 5.2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.track-info h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
/* Now playing container: artist and title on separate lines */
#now-playing {
margin: 6px 0 0;
width: 100%;
/* Reserve two lines so content changes don't shift layout */
height: 2.6rem;
display: block;
}
#now-playing .now-artist,
#now-playing .now-title {
color: var(--text-main);
font-size: 0.95rem;
font-weight: 600;
line-height: 1.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Hide visually but keep layout space */
#now-playing.hidden {
visibility: hidden;
}
.track-info p {
margin: 6px 0 0;
color: var(--text-muted);
font-size: 0.95rem;
}
/* Progress Bar (Visual) */
.progress-container {
width: 100%;
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
margin-bottom: 30px;
position: relative;
}
.progress-fill {
width: 100%; /* Live always full or pulsing */
height: 100%;
background: linear-gradient(90deg, var(--accent), #fff);
border-radius: 2px;
opacity: 0.8;
box-shadow: 0 0 10px var(--accent-glow);
}
.progress-handle {
position: absolute;
right: 0;
top: 50%;
transform: translate(50%, -50%);
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 10px rgba(255,255,255,0.8);
}
/* Controls */
.controls-section {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
margin-bottom: 30px;
}
.control-btn {
background: none;
border: none;
color: var(--text-main);
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:active {
transform: scale(0.9);
}
.control-btn.secondary {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
.control-btn.primary {
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05));
border: 1px solid rgba(255,255,255,0.3);
box-shadow: 0 8px 20px rgba(0,0,0,0.2), inset 0 0 10px rgba(255,255,255,0.1);
color: #fff;
}
.control-btn.primary svg {
filter: drop-shadow(0 0 5px var(--accent-glow));
}
/* Playing state - pulsing glow ring */
.control-btn.primary.playing {
animation: pulse-ring 2s ease-in-out infinite;
}
@keyframes pulse-ring {
0%, 100% {
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
inset 0 0 10px rgba(255,255,255,0.1),
0 0 0 0 rgba(223, 166, 255, 0.7);
}
50% {
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
inset 0 0 10px rgba(255,255,255,0.1),
0 0 0 8px rgba(223, 166, 255, 0);
}
}
/* Icon container prevents layout jump */
.icon-container {
position: relative;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-container svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.hidden {
display: none !important;
}
/* Volume */
.volume-section {
display: flex;
align-items: center;
gap: 12px;
margin-top: auto;
padding: 0 10px;
}
.slider-container {
flex: 1;
}
/* Make slider interactive when the parent card is draggable */
.slider-container,
input[type=range] {
-webkit-app-region: no-drag;
}
input[type=range] {
width: 100%;
background: transparent;
-webkit-appearance: none;
appearance: none;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: rgba(255,255,255,0.2);
border-radius: 2px;
}
input[type=range]::-webkit-slider-thumb {
height: 16px;
width: 16px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
-webkit-appearance: none;
margin-top: -6px; /* align with track */
box-shadow: 0 0 10px rgba(0,0,0,0.2);
}
#volume-value {
font-size: 0.8rem;
font-weight: 500;
width: 30px;
text-align: right;
}
.icon-btn.small {
padding: 0;
width: 24px;
height: 24px;
}
/* Cast Overlay (Beautified as per layout2_plan.md) */
.overlay {
position: fixed;
inset: 0;
background: rgba(20, 10, 35, 0.45);
backdrop-filter: blur(14px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.overlay:not(.hidden) {
opacity: 1;
pointer-events: auto;
}
/* Modal */
.modal {
width: min(420px, calc(100vw - 48px));
padding: 22px;
border-radius: 22px;
background: rgba(30, 30, 40, 0.82);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 30px 80px rgba(0,0,0,0.6);
color: #fff;
animation: pop 0.22s ease;
-webkit-app-region: no-drag;
}
@keyframes pop {
from { transform: scale(0.94); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.modal h2 {
margin: 0 0 14px;
text-align: center;
font-size: 20px;
}
/* Device list */
.device-list {
list-style: none;
padding: 10px 5px;
margin: 0 0 18px;
max-height: 360px;
overflow-y: auto;
}
/* Stations grid to show cards (used for stations overlay) */
.stations-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 8px;
}
.station-card {
list-style: none;
padding: 12px;
border-radius: 14px;
cursor: pointer;
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
border: 1px solid rgba(255,255,255,0.06);
display: flex;
gap: 12px;
align-items: center;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s;
}
.station-card:hover {
transform: translateY(-6px);
box-shadow: 0 18px 40px rgba(0,0,0,0.45);
}
.station-card.selected {
background: linear-gradient(135deg, #c77dff, #8b5cf6);
color: #111;
box-shadow: 0 10px 30px rgba(199,125,255,0.22);
}
.station-card-left {
width: 56px;
height: 56px;
flex: 0 0 56px;
display:flex;
align-items:center;
justify-content:center;
}
.station-card-logo {
width: 56px;
height: 56px;
object-fit:contain;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(0,0,0,0.35);
background: rgba(255,255,255,0.02);
}
.station-card-fallback {
width: 56px;
height: 56px;
border-radius: 10px;
display:flex;
align-items:center;
justify-content:center;
font-weight:800;
font-size:1.2rem;
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
color: var(--text-main);
}
.station-card-body {
display:flex;
flex-direction:column;
gap:3px;
overflow:hidden;
}
.station-card-title {
font-weight:700;
font-size:0.95rem;
line-height:1.1;
}
.station-card-sub {
font-size:0.8rem;
color: rgba(255,255,255,0.7);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
/* Device row */
.device {
padding: 12px 14px;
border-radius: 14px;
margin-bottom: 8px;
cursor: pointer;
background: rgba(255,255,255,0.05);
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
text-align: left;
}
.device:hover {
background: rgba(255,255,255,0.10);
transform: translateY(-1px);
}
.device .device-main {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
}
.device .device-sub {
margin-top: 3px;
font-size: 12px;
opacity: 0.7;
color: var(--text-muted);
}
/* Selected device */
.device.selected {
background: linear-gradient(135deg, #c77dff, #8b5cf6);
box-shadow: 0 0 18px rgba(199,125,255,0.65);
color: #111;
}
.device.selected .device-main,
.device.selected .device-sub {
color: #111;
}
.device.selected .device-sub {
opacity: 0.85;
}
/* Cancel button */
.btn.cancel {
width: 100%;
padding: 12px;
border-radius: 999px;
border: none;
background: #d16b7d;
color: #fff;
font-size: 15px;
cursor: pointer;
transition: transform 0.15s ease, background 0.2s;
font-weight: 600;
}
.btn.cancel:hover {
transform: scale(1.02);
background: #e17c8d;
}
/* Editor specific tweaks */
.modal form input {
outline: none;
}
/* Ensure editor overlay input fields look consistent */
#editor-list .device {
display: block;
}
.btn.edit-btn, .btn.delete-btn {
padding: 8px 10px;
border-radius: 10px;
border: none;
color: #fff;
font-weight: 700;
cursor: pointer;
}
#add-station-form button.btn {
border-radius: 10px;
}
/* Make modal form inputs visible on dark translucent background */
.modal input,
.modal textarea,
.modal select {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.12);
color: var(--text-main);
padding: 10px 12px;
border-radius: 8px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.02);
}
.modal input::placeholder,
.modal textarea::placeholder {
color: rgba(255,255,255,0.55);
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 700;
}

48
webapp/sw.js Normal file
View File

@@ -0,0 +1,48 @@
const CACHE_NAME = 'radiocast-core-v1';
const CORE_ASSETS = [
'.',
'index.html',
'main.js',
'styles.css',
'stations.json',
'assets/favicon_io/android-chrome-192x192.png',
'assets/favicon_io/android-chrome-512x512.png',
'assets/favicon_io/apple-touch-icon.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
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;
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('index.html');
return new Response('', { status: 503, statusText: 'Service Unavailable' });
});
})
);
});

13
webapp/vite.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import path from 'path';
// Allow Vite dev server to read files from parent folder so we can import
// the existing `src` code without copying it.
export default defineConfig({
server: {
fs: {
// allow access to parent workspace root
allow: [path.resolve(__dirname, '..')]
}
}
});