tools: add sync-version.js to sync package.json -> Tauri files

- Add tools/sync-version.js script to read root package.json version
  and update src-tauri/tauri.conf.json and src-tauri/Cargo.toml.
- Update only the [package] version line in Cargo.toml to preserve formatting.
- Include JSON read/write helpers and basic error handling/reporting.
This commit is contained in:
2026-01-13 07:21:51 +01:00
parent abb7cafaed
commit 694f335408
50 changed files with 1128 additions and 6186 deletions

View File

@@ -1,6 +1,7 @@
# ROLE: Senior Desktop Audio Engineer & Tauri Architect # ROLE: Senior Desktop Audio Engineer & Tauri Architect
You are an expert in: You are an expert in:
- Tauri (Rust backend + system WebView frontend) - Tauri (Rust backend + system WebView frontend)
- Native audio streaming (FFmpeg, GStreamer, CPAL, Rodio) - Native audio streaming (FFmpeg, GStreamer, CPAL, Rodio)
- Desktop media players - Desktop media players
@@ -15,16 +16,19 @@ You are working on an existing project named **Taurus RadioPlayer**.
This is a **Tauri desktop application**, NOT Electron. This is a **Tauri desktop application**, NOT Electron.
### Current architecture: ### Current architecture
- Frontend: Vanilla HTML / CSS / JS served in WebView - Frontend: Vanilla HTML / CSS / JS served in WebView
- Backend: Rust (Tauri commands) - Backend: Rust (Tauri commands)
- Audio: **HTML5 Audio API (new Audio())** - Audio: **Native player (FFmpeg decode + CPAL output)** via Tauri commands (`player_play/stop/set_volume/get_state`)
- Casting: Google Cast via Node.js sidecar (`castv2-client`) - Casting: Google Cast via Node.js sidecar (`castv2-client`)
- Stations: JSON file + user-defined stations in `localStorage` - Stations: JSON file + user-defined stations in `localStorage`
- Platforms: Windows, Linux, macOS - Platforms: Windows, Linux, macOS
### Critical limitation: ### Critical limitation
HTML5 audio is insufficient for:
Browser/HTML5 audio is insufficient for:
- stable radio streaming - stable radio streaming
- buffering control - buffering control
- reconnection - reconnection
@@ -52,22 +56,26 @@ This is an **incremental upgrade**, not a rewrite.
- UI remains WebView-based (HTML/CSS/JS) - UI remains WebView-based (HTML/CSS/JS)
- JS communicates only via Tauri `invoke()` - JS communicates only via Tauri `invoke()`
- Audio decoding and playback are handled natively - Audio decoding and playback are handled natively
- One decoded stream can be routed to: - Local playback: FFmpeg decodes to PCM and CPAL outputs to speakers
- local speakers - Casting (preferred): backend starts a **cast tap** that reuses the already-decoded PCM stream and re-encodes it to an MP3 HTTP stream (`-listen 1`) on the LAN; the sidecar casts that local URL
- cast devices - Casting (fallback): backend can still run a standalone URL→MP3 proxy when the tap cannot be started
- Casting logic may remain temporarily in the sidecar - Casting logic may remain temporarily in the sidecar
Note: “Reuse decoded audio” here means: one FFmpeg decode → PCM → fan-out to CPAL (local) and FFmpeg encode/listen (cast).
--- ---
## TECHNICAL DIRECTIVES (MANDATORY) ## TECHNICAL DIRECTIVES (MANDATORY)
### 1. Frontend rules ### 1. Frontend rules
- DO NOT redesign HTML or CSS - DO NOT redesign HTML or CSS
- DO NOT introduce frameworks (React, Vue, etc.) - DO NOT introduce frameworks (React, Vue, etc.)
- Only replace JS logic that currently uses `new Audio()` - Keep playback controlled via backend commands (no `new Audio()` usage)
- All playback must go through backend commands - All playback must go through backend commands
### 2. Backend rules ### 2. Backend rules
- Prefer **Rust-native solutions** - Prefer **Rust-native solutions**
- Acceptable audio stacks: - Acceptable audio stacks:
- FFmpeg + CPAL / Rodio - FFmpeg + CPAL / Rodio
@@ -84,8 +92,9 @@ This is an **incremental upgrade**, not a rewrite.
- thread safety - thread safety
### 3. Casting rules ### 3. Casting rules
- Do not break existing Chromecast support - Do not break existing Chromecast support
- Prefer reusing decoded audio where possible - Prefer reusing backend-controlled audio where possible (e.g., Cast via local proxy instead of sending station URL directly)
- Do not introduce browser-based casting - Do not introduce browser-based casting
- Sidecar removal is OPTIONAL, not required now - Sidecar removal is OPTIONAL, not required now
@@ -94,12 +103,14 @@ This is an **incremental upgrade**, not a rewrite.
## MIGRATION STRATEGY (VERY IMPORTANT) ## MIGRATION STRATEGY (VERY IMPORTANT)
You must: You must:
- Work in **small, safe steps** - Work in **small, safe steps**
- Clearly explain what files change and why - Clearly explain what files change and why
- Never delete working functionality without replacement - Never delete working functionality without replacement
- Prefer additive refactors over destructive ones - Prefer additive refactors over destructive ones
Each response should: Each response should:
1. Explain intent 1. Explain intent
2. Show concrete code 2. Show concrete code
3. State which file is modified 3. State which file is modified
@@ -110,6 +121,7 @@ Each response should:
## WHAT YOU SHOULD PRODUCE ## WHAT YOU SHOULD PRODUCE
You may generate: You may generate:
- Rust code (Tauri commands, audio engine) - Rust code (Tauri commands, audio engine)
- JS changes (invoke-based playback) - JS changes (invoke-based playback)
- Architecture explanations - Architecture explanations
@@ -118,6 +130,7 @@ You may generate:
- Warnings about pitfalls - Warnings about pitfalls
You MUST NOT: You MUST NOT:
- Suggest Electron or Flutter - Suggest Electron or Flutter
- Suggest full rewrites - Suggest full rewrites
- Ignore existing sidecar or station model - Ignore existing sidecar or station model
@@ -148,6 +161,7 @@ The WebView is a **control surface**, not a media engine.
## FIRST TASK WHEN STARTING ## FIRST TASK WHEN STARTING
Begin by: Begin by:
1. Identifying all HTML5 Audio usage 1. Identifying all HTML5 Audio usage
2. Proposing the native audio engine design 2. Proposing the native audio engine design
3. Defining the minimal command interface 3. Defining the minimal command interface

View File

@@ -36,7 +36,13 @@ Before you begin, ensure you have the following installed on your machine:
To start the application in development mode (with hot-reloading for frontend changes): To start the application in development mode (with hot-reloading for frontend changes):
```bash ```bash
npm run tauri dev npm run dev
```
If you want FFmpeg to be bundled into `src-tauri/resources/` for local/native playback during dev, use:
```bash
npm run dev:native
``` ```
This command will: This command will:
@@ -50,7 +56,7 @@ To create an optimized, standalone executable for your operating system:
1. **Run the build command**: 1. **Run the build command**:
```bash ```bash
npm run tauri build npm run build
``` ```
2. **Locate the artifacts**: 2. **Locate the artifacts**:
@@ -67,7 +73,9 @@ To create an optimized, standalone executable for your operating system:
* `styles.css`: Application styling. * `styles.css`: Application styling.
* `stations.json`: Configuration file for available radio streams. * `stations.json`: Configuration file for available radio streams.
* **`src-tauri/`**: Rust backend code. * **`src-tauri/`**: Rust backend code.
* `src/main.rs`: The entry point for the Rust process. Handles Google Cast discovery and playback logic. * `src/lib.rs`: Tauri command layer (native player commands, Cast commands, utility HTTP helpers).
* `src/player.rs`: Native audio engine (FFmpeg decode → PCM ring buffer → CPAL output).
* `src/main.rs`: Rust entry point (wires the Tauri app; most command logic lives in `lib.rs`).
* `tauri.conf.json`: Configuration for the Tauri app (window size, permissions, package info). * `tauri.conf.json`: Configuration for the Tauri app (window size, permissions, package info).
## Customization ## Customization

View File

@@ -6,17 +6,20 @@ This document describes the desktop (Tauri) application architecture, build pipe
- **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) - **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) - **Tauri host (Rust)**: Command layer + device discovery in [src-tauri/src/lib.rs](src-tauri/src/lib.rs)
- **Native audio engine (Rust)**: FFmpeg decode + CPAL output in [src-tauri/src/player.rs](src-tauri/src/player.rs)
- **Cast sidecar (Node executable)**: Google Cast control via `castv2-client` in [sidecar/index.js](sidecar/index.js) - **Cast sidecar (Node executable)**: Google Cast control via `castv2-client` in [sidecar/index.js](sidecar/index.js)
- **Packaging utilities**: - **Packaging utilities**:
- Sidecar binary copy/rename step: [tools/copy-binaries.js](tools/copy-binaries.js) - 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) - Windows EXE icon patch: [tools/post-build-rcedit.js](tools/post-build-rcedit.js)
- Optional FFmpeg bundling helper: [tools/copy-ffmpeg.js](tools/copy-ffmpeg.js) (see [tools/ffmpeg/README.md](tools/ffmpeg/README.md))
Data flow: Data flow:
1. UI actions call JS functions in `main.js`. 1. UI actions call JS functions in `main.js`.
2. When in **Cast mode**, JS calls Tauri commands via `window.__TAURI__.core.invoke()`. 2. JS calls Tauri commands via `window.__TAURI__.core.invoke()` (for both local playback and casting).
3. The Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`. 3. In **Local mode**, Rust spawns FFmpeg and plays decoded PCM via CPAL.
4. On `cast_play/stop/volume`, Rust spawns (or reuses) a **sidecar process**, then sends newline-delimited JSON commands to the sidecar stdin. 4. In **Cast mode**, the Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`.
5. 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 ## Running and building
@@ -113,6 +116,29 @@ When a device is resolved:
### Commands ### Commands
### Native player commands (local playback)
Local playback is handled by the Rust engine in [src-tauri/src/player.rs](src-tauri/src/player.rs). The UI controls it using these commands:
#### `player_play(url: String) -> Result<(), String>`
- Starts native playback of the provided stream URL.
- Internally spawns FFmpeg to decode into `s16le` PCM and feeds a ring buffer consumed by a CPAL output stream.
- Reports `buffering``playing` based on buffer fill/underrun.
#### `player_stop() -> Result<(), String>`
- Stops the native pipeline and updates state.
#### `player_set_volume(volume: f32) -> Result<(), String>`
- Sets volume in range `[0, 1]`.
#### `player_get_state() -> Result<PlayerState, String>`
- Returns `{ status, url, volume, error }`.
- Used by the UI to keep status text and play/stop button in sync.
#### `list_cast_devices() -> Result<Vec<String>, String>` #### `list_cast_devices() -> Result<Vec<String>, String>`
- Returns the sorted list of discovered Cast device names. - Returns the sorted list of discovered Cast device names.
@@ -214,7 +240,8 @@ State is tracked in JS:
#### Local mode #### Local mode
- Uses `new Audio()` and sets `audio.src = station.url`. - Uses backend invokes: `player_play`, `player_stop`, `player_set_volume`.
- The UI polls `player_get_state` to reflect `buffering/playing/stopped/error`.
#### Cast mode #### Cast mode
@@ -274,7 +301,7 @@ Note:
- `#play-btn` - `#play-btn`
- Toggles play/stop (`togglePlay()`): - Toggles play/stop (`togglePlay()`):
- Local mode: `audio.play()` / `audio.pause()`. - Local mode: `invoke('player_play')` / `invoke('player_stop')`.
- Cast mode: `invoke('cast_play')` / `invoke('cast_stop')`. - Cast mode: `invoke('cast_play')` / `invoke('cast_stop')`.
- `#prev-btn` - `#prev-btn`
- Previous station (`playPrev()``setStationByIndex()`). - Previous station (`playPrev()``setStationByIndex()`).
@@ -284,7 +311,7 @@ Note:
### Volume ### Volume
- `#volume-slider` - `#volume-slider`
- Local: sets `audio.volume`. - Local: `invoke('player_set_volume')`.
- Cast: `invoke('cast_set_volume')`. - Cast: `invoke('cast_set_volume')`.
- Persists `localStorage.volume`. - Persists `localStorage.volume`.
- `#mute-btn` - `#mute-btn`

View File

@@ -1,11 +0,0 @@
This folder is not a full Android Studio project.
The buildable Android Studio/Gradle project is generated by Tauri at:
- src-tauri/gen/android
If you haven't generated it yet, run from the repo root:
- .\node_modules\.bin\tauri.cmd android init --ci
Then open `src-tauri/gen/android` in Android Studio and build the APK/AAB.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1 +0,0 @@
{"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"}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

Before

Width:  |  Height:  |  Size: 995 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
</svg>

Before

Width:  |  Height:  |  Size: 289 B

View File

@@ -1,160 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radio Player</title>
<link rel="stylesheet" href="styles.css">
<script src="main.js" defer type="module"></script>
</head>
<body>
<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>
<button id="menu-btn" class="icon-btn" aria-label="Menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="header-info" data-tauri-drag-region>
<span class="app-title">Radio1 Player</span>
<span class="status-indicator" id="status-indicator">
<span class="status-dot"></span>
<span id="status-text">Ready</span>
<span id="engine-badge" class="engine-badge" title="Playback engine">FFMPEG</span>
</span>
</div>
<div class="header-buttons">
<button id="cast-toggle-btn" class="icon-btn" aria-label="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>
<button id="close-btn" class="icon-btn close-btn" aria-label="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>
</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>
<p id="station-subtitle"></p>
</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>
</main>
</div>
</body>
</html>

View File

@@ -1,386 +0,0 @@
const { invoke } = window.__TAURI__.core;
const { getCurrentWindow } = window.__TAURI__.window;
// State
let stations = [];
let currentIndex = 0;
let isPlaying = false;
let currentMode = 'local'; // 'local' | 'cast'
let currentCastDevice = null;
// Local playback is handled natively by the Tauri backend (player_* commands).
// UI Elements
const stationNameEl = document.getElementById('station-name');
const stationSubtitleEl = document.getElementById('station-subtitle');
const statusTextEl = document.getElementById('status-text');
const statusDotEl = document.querySelector('.status-dot');
const engineBadgeEl = document.getElementById('engine-badge');
const playBtn = document.getElementById('play-btn');
const iconPlay = document.getElementById('icon-play');
const iconStop = document.getElementById('icon-stop');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
const volumeSlider = document.getElementById('volume-slider');
const volumeValue = document.getElementById('volume-value');
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');
// Init
async function init() {
await loadStations();
setupEventListeners();
updateUI();
updateEngineBadge();
}
function updateEngineBadge() {
if (!engineBadgeEl) return;
const kind = currentMode === 'cast' ? 'cast' : 'ffmpeg';
const label = kind === 'cast' ? 'CAST' : 'FFMPEG';
const title = kind === 'cast' ? 'Google Cast playback' : 'Native playback (FFmpeg)';
const iconSvg = kind === 'cast'
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 16.1A5 5 0 0 1 5.9 20" />
<path d="M2 12.05A9 9 0 0 1 9.95 20" />
<path d="M2 8V6a14 14 0 0 1 14 14h-2" />
</svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 15V9" />
<path d="M8 19V5" />
<path d="M12 16V8" />
<path d="M16 18V6" />
<path d="M20 15V9" />
</svg>`;
engineBadgeEl.innerHTML = `${iconSvg}<span>${label}</span>`;
engineBadgeEl.title = title;
engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html');
engineBadgeEl.classList.add(`engine-${kind}`);
}
async function loadStations() {
try {
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`.
stations = raw
.map((s) => {
// If already in the old format, keep as-is
if (s.name && s.url) return s;
const name = s.title || s.id || s.name || 'Unknown';
// Prefer liveAudio, fall back to liveVideo or any common fields
const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || '';
return {
id: s.id || name,
name,
url,
logo: s.logo || s.poster || '',
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
raw: s,
};
})
// Filter out disabled stations and those without a stream URL
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
if (stations.length > 0) {
currentIndex = 0;
loadStation(currentIndex);
}
} catch (e) {
console.error('Failed to load stations', e);
statusTextEl.textContent = 'Error loading stations';
}
}
function setupEventListeners() {
playBtn.addEventListener('click', togglePlay);
prevBtn.addEventListener('click', playPrev);
nextBtn.addEventListener('click', playNext);
volumeSlider.addEventListener('input', handleVolumeInput);
castBtn.addEventListener('click', openCastOverlay);
closeOverlayBtn.addEventListener('click', closeCastOverlay);
// Close overlay on background click
castOverlay.addEventListener('click', (e) => {
if (e.target === castOverlay) closeCastOverlay();
});
// Close button
document.getElementById('close-btn').addEventListener('click', async () => {
const appWindow = getCurrentWindow();
await appWindow.close();
});
// Menu button - explicit functionality or placeholder?
// For now just log or maybe show about
document.getElementById('menu-btn').addEventListener('click', () => {
openStationsOverlay();
});
// Hotkeys?
}
function loadStation(index) {
if (index < 0 || index >= stations.length) return;
const station = stations[index];
stationNameEl.textContent = station.name;
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
// 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) {
logoImgEl.src = station.logo;
logoImgEl.classList.remove('hidden');
logoTextEl.classList.add('hidden');
} else {
// Fallback to single-letter/logo text
logoImgEl.src = '';
logoImgEl.classList.add('hidden');
logoTextEl.textContent = String(station.name).trim();
logoTextEl.classList.add('logo-name');
logoTextEl.classList.remove('hidden');
}
}
async function togglePlay() {
if (isPlaying) {
await stop();
} else {
await play();
}
}
async function play() {
const station = stations[currentIndex];
if (!station) return;
statusTextEl.textContent = 'Buffering...';
statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading
if (currentMode === 'local') {
try {
const vol = volumeSlider.value / 100;
await invoke('player_set_volume', { volume: vol }).catch(() => {});
await invoke('player_play', { url: station.url });
isPlaying = true;
updateUI();
} catch (e) {
console.error('Playback failed', e);
statusTextEl.textContent = 'Error';
}
} else if (currentMode === 'cast' && currentCastDevice) {
// Cast logic
try {
await invoke('cast_play', { deviceName: currentCastDevice, url: station.url });
isPlaying = true;
// Sync volume
const vol = volumeSlider.value / 100;
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: vol });
updateUI();
} catch (e) {
console.error('Cast failed', e);
statusTextEl.textContent = 'Cast Error';
currentMode = 'local'; // Fallback
updateUI();
}
}
}
async function stop() {
if (currentMode === 'local') {
try {
await invoke('player_stop');
} catch (e) {
console.error(e);
}
} else if (currentMode === 'cast' && currentCastDevice) {
try {
await invoke('cast_stop', { deviceName: currentCastDevice });
} catch (e) {
console.error(e);
}
}
isPlaying = false;
updateUI();
}
async function playNext() {
if (stations.length === 0) return;
// 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);
if (wasPlaying) await play();
}
async function playPrev() {
if (stations.length === 0) return;
const wasPlaying = isPlaying;
if (wasPlaying) await stop();
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
loadStation(currentIndex);
if (wasPlaying) await play();
}
function updateUI() {
// Play/Stop Button
if (isPlaying) {
iconPlay.classList.add('hidden');
iconStop.classList.remove('hidden');
playBtn.classList.add('playing'); // Add pulsing ring animation
statusTextEl.textContent = 'Playing';
statusDotEl.style.backgroundColor = 'var(--success)';
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
} else {
iconPlay.classList.remove('hidden');
iconStop.classList.add('hidden');
playBtn.classList.remove('playing'); // Remove pulsing ring
statusTextEl.textContent = 'Ready';
statusDotEl.style.backgroundColor = 'var(--text-muted)';
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
}
updateEngineBadge();
}
function handleVolumeInput() {
const val = volumeSlider.value;
volumeValue.textContent = `${val}%`;
const decimals = val / 100;
if (currentMode === 'local') {
invoke('player_set_volume', { volume: decimals }).catch(() => {});
} else if (currentMode === 'cast' && currentCastDevice) {
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
}
}
// Cast Logic
async function openCastOverlay() {
castOverlay.classList.remove('hidden');
castOverlay.setAttribute('aria-hidden', 'false');
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
try {
const devices = await invoke('list_cast_devices');
deviceListEl.innerHTML = '';
// Add "This Computer" option
const localLi = document.createElement('li');
localLi.className = 'device' + (currentMode === 'local' ? ' selected' : '');
localLi.innerHTML = '<div class="device-main">This Computer</div><div class="device-sub">Local Playback</div>';
localLi.onclick = () => selectCastDevice(null);
deviceListEl.appendChild(localLi);
if (devices.length > 0) {
devices.forEach(d => {
const li = document.createElement('li');
li.className = 'device' + (currentMode === 'cast' && currentCastDevice === d ? ' selected' : '');
li.innerHTML = `<div class="device-main">${d}</div><div class="device-sub">Google Cast Speaker</div>`;
li.onclick = () => selectCastDevice(d);
deviceListEl.appendChild(li);
});
}
} catch (e) {
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Error</div><div class="device-sub">${e}</div></li>`;
}
}
function closeCastOverlay() {
castOverlay.classList.add('hidden');
castOverlay.setAttribute('aria-hidden', 'true');
}
async function selectCastDevice(deviceName) {
closeCastOverlay();
// If checking same device, do nothing
if (deviceName === currentCastDevice) return;
// If switching mode, stop current playback
if (isPlaying) {
await stop();
}
if (deviceName) {
currentMode = 'cast';
currentCastDevice = deviceName;
castBtn.style.color = 'var(--success)';
} else {
currentMode = 'local';
currentCastDevice = null;
castBtn.style.color = 'var(--text-main)';
}
updateUI();
// Auto-play if we were playing? Let's stay stopped to be safe/explicit
// Or auto-play for better UX?
// Let's prompt user to play.
}
window.addEventListener('DOMContentLoaded', init);
// Open overlay and show list of stations (used by menu/hamburger)
function openStationsOverlay() {
castOverlay.classList.remove('hidden');
castOverlay.setAttribute('aria-hidden', 'false');
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Loading...</div><div class="device-sub">Preparing stations</div></li>';
// If stations not loaded yet, show message
if (!stations || stations.length === 0) {
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
return;
}
deviceListEl.innerHTML = '';
stations.forEach((s, idx) => {
const li = document.createElement('li');
li.className = 'device' + (currentIndex === idx ? ' selected' : '');
const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || '');
li.innerHTML = `<div class="device-main">${s.name}</div><div class="device-sub">${subtitle}</div>`;
li.onclick = async () => {
// Always switch to local playback when selecting from stations menu
currentMode = 'local';
currentCastDevice = null;
castBtn.style.color = 'var(--text-main)';
// Select and play
currentIndex = idx;
loadStation(currentIndex);
closeCastOverlay();
try {
await play();
} catch (e) {
console.error('Failed to play station from menu', e);
}
};
deviceListEl.appendChild(li);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,671 +0,0 @@
: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);
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: 8px; /* 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: 11px 24px 24px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
-webkit-app-region: drag; /* Draggable area */
}
/* Nudge toolbar a bit higher */
.header-top-row {
padding-top: 2px;
}
.header-info {
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.app-title {
font-weight: 600;
font-size: 1rem;
color: var(--text-main);
}
.status-indicator {
font-size: 0.8rem;
color: var(--success);
margin-top: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.engine-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
letter-spacing: 0.6px;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
color: var(--text-main);
opacity: 0.9;
}
.engine-badge svg {
width: 12px;
height: 12px;
display: block;
}
.engine-ffmpeg { border-color: rgba(125,255,179,0.30); }
.engine-cast { border-color: rgba(223,166,255,0.35); }
.engine-html { border-color: rgba(255,255,255,0.22); }
.status-dot {
width: 6px;
height: 6px;
background-color: var(--success);
border-radius: 50%;
box-shadow: 0 0 8px var(--success);
}
.icon-btn {
background: none;
border: none;
color: var(--text-main);
padding: 8px;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
-webkit-app-region: no-drag; /* Buttons clickable */
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.header-buttons {
display: flex;
gap: 4px;
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: 190px;
height: 190px;
border-radius: 24px;
padding: 6px; /* spacing for ring */
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0));
box-shadow: 5px 5px 15px rgba(0,0,0,0.1), inset 1px 1px 2px rgba(255,255,255,0.3);
}
.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);
}
.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;
left: 0;
right: 0;
bottom: 0;
height: 128px;
z-index: 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-text.logo-name {
font-size: clamp(1.1rem, 5.5vw, 2.2rem);
font-weight: 800;
font-style: normal;
max-width: 88%;
text-align: center;
line-height: 1.12;
padding: 0 12px;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.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;
}
/* 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;
}
.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;
}
/* Track Info */
.track-info {
text-align: center;
margin-bottom: 20px;
}
.track-info h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.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-top: 12px;
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;
}
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;
}
/* 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;
}

91
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"cross-env": "^7.0.3",
"npx": "^3.0.0", "npx": "^3.0.0",
"rcedit": "^1.1.2" "rcedit": "^1.1.2"
} }
@@ -255,6 +256,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -303,6 +338,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -1661,6 +1703,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/rcedit": { "node_modules/rcedit": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/rcedit/-/rcedit-1.1.2.tgz", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-1.1.2.tgz",
@@ -1682,6 +1734,45 @@
"rimraf": "bin.js" "rimraf": "bin.js"
} }
}, },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -1,17 +1,20 @@
{ {
"name": "radio-tauri", "name": "radio-tauri",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "tauri dev",
"dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev", "dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev",
"ffmpeg:download": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/download-ffmpeg.ps1", "ffmpeg:download": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/download-ffmpeg.ps1",
"build": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri build && node tools/post-build-rcedit.js", "version:sync": "node tools/sync-version.js",
"build": "node tools/sync-version.js && node tools/copy-binaries.js && node tools/copy-ffmpeg.js && node tools/write-build-flag.js set && tauri build && node tools/post-build-rcedit.js && node tools/write-build-flag.js clear",
"build:devlike": "node tools/sync-version.js && node tools/copy-binaries.js && node tools/copy-ffmpeg.js && node tools/write-build-flag.js set --debug && cross-env RADIO_DEBUG_DEVTOOLS=1 tauri build && node tools/post-build-rcedit.js && node tools/write-build-flag.js clear",
"tauri": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri" "tauri": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"cross-env": "^7.0.3",
"npx": "^3.0.0", "npx": "^3.0.0",
"rcedit": "^1.1.2" "rcedit": "^1.1.2"
} }

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#7b7fd8"/>
<stop offset="1" stop-color="#b57cf2"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" rx="24" fill="url(#g)" />
<g fill="white" transform="translate(32,32)">
<circle cx="48" cy="48" r="28" fill="rgba(255,255,255,0.15)" />
<path d="M24 48c6-10 16-16 24-16v8c-6 0-14 4-18 12s-2 12 0 12 6-2 10-6c4-4 10-6 14-6v8c-6 0-14 4-18 12s-2 12 0 12" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" />
<text x="96" y="98" font-family="sans-serif" font-size="18" fill="white" opacity="0.95">Radio</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 815 B

View File

@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Radio Player</title>
<!-- Google Cast Receiver SDK -->
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="app">
<h1>Radio Player</h1>
<p id="status">Ready</p>
<div id="artwork">
<img src="assets/logo.svg" alt="Radio Player" />
</div>
<p id="station">Radio Live Stream</p>
</div>
<script src="receiver.js"></script>
</body>
</html>

View File

@@ -1,73 +0,0 @@
/* Receiver for "Radio Player" using CAF Receiver SDK */
(function () {
const STREAM_URL = 'https://live.radio1.si/Radio1MB';
function $(id) { return document.getElementById(id); }
document.addEventListener('DOMContentLoaded', () => {
const context = cast.framework.CastReceiverContext.getInstance();
const playerManager = context.getPlayerManager();
const statusEl = $('status');
const stationEl = $('station');
// Intercept LOAD to enforce correct metadata for LIVE audio
playerManager.setMessageInterceptor(
cast.framework.messages.MessageType.LOAD,
(request) => {
if (!request || !request.media) return request;
request.media.contentId = request.media.contentId || STREAM_URL;
request.media.contentType = 'audio/mpeg';
request.media.streamType = cast.framework.messages.StreamType.LIVE;
request.media.metadata = request.media.metadata || {};
request.media.metadata.title = request.media.metadata.title || 'Radio 1';
request.media.metadata.images = request.media.metadata.images || [{ url: 'assets/logo.svg' }];
return request;
}
);
// Update UI on player state changes
playerManager.addEventListener(
cast.framework.events.EventType.PLAYER_STATE_CHANGED,
() => {
const state = playerManager.getPlayerState();
switch (state) {
case cast.framework.messages.PlayerState.PLAYING:
statusEl.textContent = 'Playing';
break;
case cast.framework.messages.PlayerState.PAUSED:
statusEl.textContent = 'Paused';
break;
case cast.framework.messages.PlayerState.IDLE:
statusEl.textContent = 'Stopped';
break;
default:
statusEl.textContent = state;
}
}
);
// When a new media is loaded, reflect metadata (station name, artwork)
playerManager.addEventListener(cast.framework.events.EventType.LOAD, (event) => {
const media = event && event.data && event.data.media;
if (media && media.metadata) {
if (media.metadata.title) stationEl.textContent = media.metadata.title;
if (media.metadata.images && media.metadata.images[0] && media.metadata.images[0].url) {
const img = document.querySelector('#artwork img');
img.src = media.metadata.images[0].url;
}
}
});
// Optional: reflect volume in title attribute
playerManager.addEventListener(cast.framework.events.EventType.VOLUME_CHANGED, (evt) => {
const level = evt && evt.data && typeof evt.data.level === 'number' ? evt.data.level : null;
if (level !== null) statusEl.title = `Volume: ${Math.round(level * 100)}%`;
});
// Start the cast receiver context
context.start({ statusText: 'Radio Player Ready' });
});
})();

View File

@@ -1,58 +0,0 @@
html, body {
margin: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #7b7fd8, #b57cf2);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: white;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
}
#artwork {
width: 240px;
height: 240px;
margin: 20px 0;
border-radius: 24px;
overflow: hidden;
background: rgba(0,0,0,0.1);
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
#artwork img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
#status {
font-size: 18px;
opacity: 0.95;
margin: 6px 0 0 0;
}
#station {
font-size: 16px;
opacity: 0.85;
margin: 6px 0 0 0;
}
h1 {
font-size: 20px;
margin: 0 0 6px 0;
}
@media (max-width: 480px) {
#artwork { width: 160px; height: 160px; }
h1 { font-size: 18px; }
}

View File

@@ -1,206 +0,0 @@
<#
Build helper for Android (Windows PowerShell)
What it does:
- Checks for required commands (`npm`, `rustup`, `cargo`, `cargo-ndk`)
- Builds frontend (runs `npm run build` if `dist`/`build` not present)
- Copies frontend files from `dist` or `src` into `android/app/src/main/assets`
- Builds Rust native libs using `cargo-ndk` (if available) for `aarch64` and `armv7`
- Copies produced `.so` files into `android/app/src/main/jniLibs/*`
Note: This script prepares the Android project. To produce the APK, open `android/` in Android Studio and run Build -> Assemble, or run `gradlew assembleDebug` locally.
#>
Set-StrictMode -Version Latest
function Check-Command($name) {
$which = Get-Command $name -ErrorAction SilentlyContinue
return $which -ne $null
}
Write-Output "Starting Android prep script..."
if (-not (Check-Command npm)) { Write-Warning "npm not found in PATH. Install Node.js to build frontend." }
if (-not (Check-Command rustup)) { Write-Warning "rustup not found in PATH. Install Rust toolchain." }
if (-not (Check-Command cargo)) { Write-Warning "cargo not found in PATH." }
$cargoNdkAvailable = Check-Command cargo-ndk
if (-not $cargoNdkAvailable) { Write-Warning "cargo-ndk not found. Native libs will not be built. Install via 'cargo install cargo-ndk'" }
# Determine repository root (parent of the scripts folder)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$root = Split-Path -Parent $scriptDir
Push-Location $root
# Prefer Tauri-generated Android Studio project (tauri android init)
$androidRoot = Join-Path $root 'src-tauri\gen\android'
if (-not (Test-Path $androidRoot)) {
# Legacy fallback (non-Tauri project)
$androidRoot = Join-Path $root 'android'
}
function Escape-LocalPropertiesPath([string]$p) {
# local.properties expects ':' escaped and backslashes doubled on Windows.
# Use plain string replacements to avoid regex escaping pitfalls.
return ($p.Replace('\', '\\').Replace(':', '\:'))
}
# Ensure Android SDK/NDK locations are set for Gradle (local.properties)
$sdkRoot = $env:ANDROID_SDK_ROOT
if (-not $sdkRoot) { $sdkRoot = $env:ANDROID_HOME }
if (-not $sdkRoot) { $sdkRoot = Join-Path $env:LOCALAPPDATA 'Android\Sdk' }
$ndkRoot = $env:ANDROID_NDK_ROOT
if (-not $ndkRoot) { $ndkRoot = $env:ANDROID_NDK_HOME }
if (-not $ndkRoot -and (Test-Path (Join-Path $sdkRoot 'ndk'))) {
$ndkVersions = Get-ChildItem -Path (Join-Path $sdkRoot 'ndk') -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending
if ($ndkVersions -and (@($ndkVersions)).Count -gt 0) { $ndkRoot = @($ndkVersions)[0].FullName }
}
if (Test-Path $androidRoot) {
$localPropsPath = Join-Path $androidRoot 'local.properties'
$lines = @()
if ($sdkRoot) { $lines += "sdk.dir=$(Escape-LocalPropertiesPath $sdkRoot)" }
if ($ndkRoot) { $lines += "ndk.dir=$(Escape-LocalPropertiesPath $ndkRoot)" }
if ($lines.Count -gt 0) {
Set-Content -Path $localPropsPath -Value ($lines -join "`n") -Encoding ASCII
Write-Output "Wrote Android SDK/NDK config to: $localPropsPath"
}
}
# Build frontend (optional)
Write-Output "Preparing frontend files..."
$distDirs = @('dist','build')
$foundDist = $null
foreach ($d in $distDirs) {
if (Test-Path (Join-Path $root $d)) { $foundDist = $d; break }
}
if (-not $foundDist) {
# IMPORTANT: `npm run build` in this repo runs `tauri build`, which is a desktop bundling step.
# For Android prep we only need web assets, so we fall back to copying `src/` as assets.
Write-Warning "No dist/build output found — copying `src/` as assets (skipping `npm run build` to avoid desktop bundling)."
}
$assetsDst = Join-Path $androidRoot 'app\src\main\assets'
if (-not (Test-Path $assetsDst)) { New-Item -ItemType Directory -Path $assetsDst -Force | Out-Null }
if ($foundDist) {
Write-Output "Copying frontend from '$foundDist' to Android assets..."
robocopy (Join-Path $root $foundDist) $assetsDst /MIR | Out-Null
} else {
Write-Output "Copying raw 'src' to Android assets..."
robocopy (Join-Path $root 'src') $assetsDst /MIR | Out-Null
}
# Build native libs if cargo-ndk available
if ($cargoNdkAvailable) {
Write-Output "Building Rust native libs via cargo-ndk from project root: $root"
try {
# Build from the Rust crate directory `src-tauri`
$crateDir = Join-Path $root 'src-tauri'
if (-not (Test-Path (Join-Path $crateDir 'Cargo.toml'))) {
Write-Warning "Cargo.toml not found in src-tauri; skipping native build."
} else {
# Prefer Ninja generator for CMake if available (avoids Visual Studio generator issues)
# Restore env vars at the end so we don't pollute the current PowerShell session.
$oldCmakeGenerator = $env:CMAKE_GENERATOR
$oldCmakeMakeProgram = $env:CMAKE_MAKE_PROGRAM
$ninjaCmd = Get-Command ninja -ErrorAction SilentlyContinue
if ($ninjaCmd) {
Write-Output "Ninja detected at $($ninjaCmd.Source); setting CMake generator to Ninja."
$env:CMAKE_GENERATOR = 'Ninja'
$env:CMAKE_MAKE_PROGRAM = $ninjaCmd.Source
} else {
Write-Warning "Ninja not found in PATH. Installing Ninja or adding it to PATH is strongly recommended to avoid Visual Studio CMake generator on Windows."
}
# Attempt to locate Android NDK if environment variables are not set
if (-not $env:ANDROID_NDK_ROOT -and -not $env:ANDROID_NDK_HOME) {
$candidates = @()
if ($env:ANDROID_SDK_ROOT) { $candidates += Join-Path $env:ANDROID_SDK_ROOT 'ndk' }
if ($env:ANDROID_HOME) { $candidates += Join-Path $env:ANDROID_HOME 'ndk' }
$candidates += Join-Path $env:LOCALAPPDATA 'Android\sdk\ndk'
$candidates += Join-Path $env:USERPROFILE 'AppData\Local\Android\sdk\ndk'
$candidates += 'C:\Program Files (x86)\Android\AndroidNDK'
foreach ($cand in $candidates) {
if (Test-Path $cand) {
$versions = Get-ChildItem -Path $cand -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending
if ($versions -and (@($versions)).Count -gt 0) {
$ndkPath = @($versions)[0].FullName
Write-Output "Detected Android NDK at: $ndkPath"
$env:ANDROID_NDK_ROOT = $ndkPath
$env:ANDROID_NDK = $ndkPath
break
}
}
}
if (-not $env:ANDROID_NDK_ROOT) { Write-Warning "ANDROID_NDK_ROOT/ANDROID_NDK not set and no NDK found in common locations. Set ANDROID_NDK_ROOT to your NDK path." }
} else {
Write-Output "Using existing ANDROID_NDK_ROOT: $($env:ANDROID_NDK_ROOT)"
if (-not $env:ANDROID_NDK) { $env:ANDROID_NDK = $env:ANDROID_NDK_ROOT }
}
# Ensure expected external binary placeholders exist so Tauri bundling doesn't fail
$binariesDir = Join-Path $crateDir 'binaries'
if (-not (Test-Path $binariesDir)) { New-Item -ItemType Directory -Path $binariesDir -Force | Out-Null }
$placeholder1 = Join-Path $binariesDir 'RadioPlayer-aarch64-linux-android'
$placeholder2 = Join-Path $binariesDir 'RadioPlayer-armv7-linux-androideabi'
if (-not (Test-Path $placeholder1)) { New-Item -ItemType File -Path $placeholder1 -Force | Out-Null; Write-Output "Created placeholder: $placeholder1" }
if (-not (Test-Path $placeholder2)) { New-Item -ItemType File -Path $placeholder2 -Force | Out-Null; Write-Output "Created placeholder: $placeholder2" }
# If a previous build used a different CMake generator (e.g., Visual Studio), aws-lc-sys can fail with
# "Does not match the generator used previously". Clean only the aws-lc-sys CMake build dirs.
$awsLcBuildDirs = Get-ChildItem -Path (Join-Path $crateDir 'target') -Recurse -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like 'aws-lc-sys-*' }
foreach ($d in @($awsLcBuildDirs)) {
$cmakeBuildDir = Join-Path $d.FullName 'out\build'
$cmakeCache = Join-Path $cmakeBuildDir 'CMakeCache.txt'
if (Test-Path $cmakeCache) {
Write-Output "Cleaning stale CMake cache for aws-lc-sys: $cmakeBuildDir"
Remove-Item -Path $cmakeBuildDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
Push-Location $crateDir
try {
# Use API 24 to ensure libc symbols like getifaddrs/freeifaddrs are available.
# Build only the library to avoid linking the desktop binary for Android.
Write-Output "Running: cargo ndk -t arm64-v8a -t armeabi-v7a -P 24 build --release --lib (in $crateDir)"
cargo ndk -t arm64-v8a -t armeabi-v7a -P 24 build --release --lib
} finally {
Pop-Location
if ($null -eq $oldCmakeGenerator) { Remove-Item Env:\CMAKE_GENERATOR -ErrorAction SilentlyContinue } else { $env:CMAKE_GENERATOR = $oldCmakeGenerator }
if ($null -eq $oldCmakeMakeProgram) { Remove-Item Env:\CMAKE_MAKE_PROGRAM -ErrorAction SilentlyContinue } else { $env:CMAKE_MAKE_PROGRAM = $oldCmakeMakeProgram }
}
# Search for produced .so files under src-tauri/target
$soFiles = Get-ChildItem -Path (Join-Path $crateDir 'target') -Recurse -Filter "*.so" -ErrorAction SilentlyContinue
if (-not $soFiles) {
Write-Warning "No .so files found after build. Check cargo-ndk output above for errors."
} else {
foreach ($f in @($soFiles)) {
$full = $f.FullName
if ($full -match 'aarch64|aarch64-linux-android|arm64-v8a') { $abi = 'arm64-v8a' }
elseif ($full -match 'armv7|armv7-linux-androideabi|armeabi-v7a') { $abi = 'armeabi-v7a' }
else { continue }
$dst = Join-Path $androidRoot "app\src\main\jniLibs\$abi"
if (-not (Test-Path $dst)) { New-Item -ItemType Directory -Path $dst -Force | Out-Null }
Copy-Item $full -Destination $dst -Force
Write-Output "Copied $($f.Name) -> $dst"
}
}
}
} catch {
Write-Warning "cargo-ndk build failed. Exception: $($_.Exception.Message)"
if ($_.ScriptStackTrace) { Write-Output $_.ScriptStackTrace }
}
} else {
Write-Warning "Skipping native lib build (cargo-ndk missing)."
}
Write-Output "Android prep complete. Open '$androidRoot' in Android Studio and build the APK (or run './gradlew assembleDebug' in that folder)."
Pop-Location

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Cross-platform helper for Unix-like shells
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "Preparing Android assets and native libs..."
if command -v npm >/dev/null 2>&1; then
echo "Running npm install & build"
npm install
npm run build || true
fi
DIST_DIR="dist"
if [ ! -d "$DIST_DIR" ]; then DIST_DIR="build"; fi
if [ -d "$DIST_DIR" ]; then
echo "Copying $DIST_DIR -> android/app/src/main/assets"
mkdir -p android/app/src/main/assets
rsync -a --delete "$DIST_DIR/" android/app/src/main/assets/
else
echo "No dist/build found, copying src/ -> android assets"
mkdir -p android/app/src/main/assets
rsync -a --delete src/ android/app/src/main/assets/
fi
if command -v cargo-ndk >/dev/null 2>&1; then
echo "Building native libs with cargo-ndk"
cargo-ndk -t aarch64 -t armv7 build --release || true
# copy so files
find target -type f -name "*.so" | while read -r f; do
if [[ "$f" =~ aarch64|aarch64-linux-android ]]; then abi=arm64-v8a; fi
if [[ "$f" =~ armv7|armv7-linux-androideabi ]]; then abi=armeabi-v7a; fi
if [ -n "${abi-}" ]; then
mkdir -p android/app/src/main/jniLibs/$abi
cp "$f" android/app/src/main/jniLibs/$abi/
echo "Copied $f -> android/app/src/main/jniLibs/$abi/"
fi
done
else
echo "cargo-ndk not found; skipping native lib build"
fi
echo "Prepared Android project. Open android/ in Android Studio to build the APK (or run ./gradlew assembleDebug)."

2
src-tauri/Cargo.lock generated
View File

@@ -3282,7 +3282,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "radio-tauri" name = "radio-tauri"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"cpal", "cpal",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "radio-tauri" name = "radio-tauri"
version = "0.1.0" version = "0.1.1"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"

View File

@@ -1,6 +1,16 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream, UdpSocket};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex; use std::sync::Mutex;
use std::thread; use std::thread;
use std::time::Duration;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
use mdns_sd::{ServiceDaemon, ServiceEvent}; use mdns_sd::{ServiceDaemon, ServiceEvent};
use serde_json::json; use serde_json::json;
@@ -21,6 +31,21 @@ struct AppState {
known_devices: Mutex<HashMap<String, String>>, known_devices: Mutex<HashMap<String, String>>,
} }
struct CastProxy {
child: Child,
}
struct CastProxyState {
inner: Mutex<Option<CastProxy>>,
}
#[derive(serde::Serialize)]
struct CastProxyStartResult {
url: String,
// "tap" | "proxy"
mode: String,
}
// Native (non-WebView) audio player state. // Native (non-WebView) audio player state.
// Step 1: state machine + command interface only (no decoding/output yet). // Step 1: state machine + command interface only (no decoding/output yet).
struct PlayerRuntime { struct PlayerRuntime {
@@ -40,6 +65,221 @@ fn clamp01(v: f32) -> f32 {
} }
} }
fn format_http_host(ip: IpAddr) -> String {
match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!("[{v6}]"),
}
}
fn local_ip_for_peer(peer_ip: IpAddr) -> Result<IpAddr, String> {
// Trick: connect a UDP socket to the peer and read the chosen local address.
// Port number is irrelevant; no packets are sent for UDP connect().
let peer = SocketAddr::new(peer_ip, 9);
let bind_addr = match peer_ip {
IpAddr::V4(_) => "0.0.0.0:0",
IpAddr::V6(_) => "[::]:0",
};
let sock = UdpSocket::bind(bind_addr).map_err(|e| e.to_string())?;
sock.connect(peer).map_err(|e| e.to_string())?;
Ok(sock.local_addr().map_err(|e| e.to_string())?.ip())
}
fn wait_for_listen(ip: IpAddr, port: u16) {
// Best-effort: give ffmpeg a moment to bind before we tell the Chromecast.
let addr = SocketAddr::new(ip, port);
for _ in 0..50 {
if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
if let Some(mut proxy) = lock.take() {
let _ = proxy.child.kill();
let _ = proxy.child.wait();
println!("Cast proxy stopped");
}
}
fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String> {
// Standalone path (fallback): FFmpeg pulls the station URL and serves MP3 over HTTP.
// Try libmp3lame first, then fall back to the built-in "mp3" encoder if needed.
let ffmpeg = player::ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy();
let spawn = |codec: &str| -> Result<Child, String> {
let mut cmd = Command::new(&ffmpeg);
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
.arg("warning")
.arg("-reconnect")
.arg("1")
.arg("-reconnect_streamed")
.arg("1")
.arg("-reconnect_delay_max")
.arg("5")
.arg("-i")
.arg(&url)
.arg("-vn")
.arg("-c:a")
.arg(codec)
.arg("-b:a")
.arg("128k")
.arg("-f")
.arg("mp3")
.arg("-content_type")
.arg("audio/mpeg")
.arg("-listen")
.arg("1")
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"Failed to start ffmpeg cast proxy ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
)
})
};
let mut child = spawn("libmp3lame")?;
std::thread::sleep(Duration::from_millis(150));
if let Ok(Some(status)) = child.try_wait() {
if !status.success() {
eprintln!("Standalone cast proxy exited early; retrying with -c:a mp3");
child = spawn("mp3")?;
}
}
Ok(child)
}
#[tauri::command]
async fn cast_proxy_start(
state: State<'_, AppState>,
proxy_state: State<'_, CastProxyState>,
player: State<'_, PlayerRuntime>,
device_name: String,
url: String,
) -> Result<CastProxyStartResult, String> {
// Make sure ffmpeg exists before we try to cast.
player::preflight_ffmpeg_only()?;
let device_ip_str = {
let devices = state.known_devices.lock().unwrap();
devices
.get(&device_name)
.cloned()
.ok_or("Device not found")?
};
let device_ip: IpAddr = device_ip_str
.parse()
.map_err(|_| format!("Invalid device IP: {device_ip_str}"))?;
let local_ip = local_ip_for_peer(device_ip)?;
// Pick an ephemeral port.
let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
drop(listener);
let host = format_http_host(local_ip);
let proxy_url = format!("http://{host}:{port}/stream.mp3");
// Stop any existing standalone proxy first.
{
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
}
// Prefer reusing the native decoder PCM when possible.
// If the currently playing URL differs (or nothing is playing), start a headless decoder.
let snapshot = player.shared.snapshot();
let is_same_url = snapshot.url.as_deref() == Some(url.as_str());
let is_decoding = matches!(snapshot.status, player::PlayerStatus::Playing | player::PlayerStatus::Buffering);
if !(is_same_url && is_decoding) {
player
.controller
.tx
.send(PlayerCommand::PlayCast { url: url.clone() })
.map_err(|e| e.to_string())?;
}
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
let _ = player
.controller
.tx
.send(PlayerCommand::CastTapStart {
port,
reply: reply_tx,
})
.map_err(|e| e.to_string())?;
match reply_rx.recv_timeout(Duration::from_secs(2)) {
Ok(Ok(())) => {
wait_for_listen(local_ip, port);
Ok(CastProxyStartResult {
url: proxy_url,
mode: "tap".to_string(),
})
}
Ok(Err(e)) => {
eprintln!("Cast tap start failed; falling back to standalone proxy: {e}");
let mut child = spawn_standalone_cast_proxy(url, port)?;
if let Some(stderr) = child.stderr.take() {
std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().flatten() {
eprintln!("[cast-proxy ffmpeg] {line}");
}
});
}
wait_for_listen(local_ip, port);
let mut lock = proxy_state.inner.lock().unwrap();
*lock = Some(CastProxy { child });
Ok(CastProxyStartResult {
url: proxy_url,
mode: "proxy".to_string(),
})
}
Err(_) => {
eprintln!("Cast tap start timed out; falling back to standalone proxy");
let mut child = spawn_standalone_cast_proxy(url, port)?;
if let Some(stderr) = child.stderr.take() {
std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().flatten() {
eprintln!("[cast-proxy ffmpeg] {line}");
}
});
}
wait_for_listen(local_ip, port);
let mut lock = proxy_state.inner.lock().unwrap();
*lock = Some(CastProxy { child });
Ok(CastProxyStartResult {
url: proxy_url,
mode: "proxy".to_string(),
})
}
}
}
#[tauri::command]
async fn cast_proxy_stop(proxy_state: State<'_, CastProxyState>, player: State<'_, PlayerRuntime>) -> Result<(), String> {
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
Ok(())
}
#[tauri::command] #[tauri::command]
async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result<PlayerState, String> { async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result<PlayerState, String> {
Ok(player.shared.snapshot()) Ok(player.shared.snapshot())
@@ -177,8 +417,18 @@ async fn cast_play(
async fn cast_stop( async fn cast_stop(
_app: AppHandle, _app: AppHandle,
sidecar_state: State<'_, SidecarState>, sidecar_state: State<'_, SidecarState>,
proxy_state: State<'_, CastProxyState>,
player: State<'_, PlayerRuntime>,
_device_name: String, _device_name: String,
) -> Result<(), String> { ) -> Result<(), String> {
{
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
}
// Safety net: stop any active tap too.
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
let mut lock = sidecar_state.child.lock().unwrap(); let mut lock = sidecar_state.child.lock().unwrap();
if let Some(ref mut child) = *lock { if let Some(ref mut child) = *lock {
let stop_cmd = json!({ "command": "stop", "args": {} }); let stop_cmd = json!({ "command": "stop", "args": {} });
@@ -282,6 +532,12 @@ pub fn run() {
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
let player = window.app_handle().state::<PlayerRuntime>(); let player = window.app_handle().state::<PlayerRuntime>();
let _ = player.controller.tx.send(PlayerCommand::Shutdown); let _ = player.controller.tx.send(PlayerCommand::Shutdown);
// Also stop any active cast tap/proxy so we don't leave processes behind.
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
let proxy_state = window.app_handle().state::<CastProxyState>();
let mut lock = proxy_state.inner.lock().unwrap();
stop_cast_proxy_locked(&mut lock);
} }
}) })
.setup(|app| { .setup(|app| {
@@ -291,6 +547,9 @@ pub fn run() {
app.manage(SidecarState { app.manage(SidecarState {
child: Mutex::new(None), child: Mutex::new(None),
}); });
app.manage(CastProxyState {
inner: Mutex::new(None),
});
// Player scaffolding: leak shared state to get a 'static reference for the // Player scaffolding: leak shared state to get a 'static reference for the
// long-running thread without complex lifetime plumbing. // long-running thread without complex lifetime plumbing.
@@ -342,6 +601,8 @@ pub fn run() {
cast_play, cast_play,
cast_stop, cast_stop,
cast_set_volume, cast_set_volume,
cast_proxy_start,
cast_proxy_stop,
// allow frontend to request arbitrary URLs via backend (bypass CORS) // allow frontend to request arbitrary URLs via backend (bypass CORS)
fetch_url, fetch_url,
// fetch remote images via backend (data: URL), helps with mixed-content // fetch remote images via backend (data: URL), helps with mixed-content

View File

@@ -11,6 +11,21 @@ use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::HeapRb; use ringbuf::HeapRb;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn command_hidden(program: &OsString) -> Command {
let mut cmd = Command::new(program);
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum PlayerStatus { pub enum PlayerStatus {
@@ -53,8 +68,16 @@ impl PlayerShared {
#[derive(Debug)] #[derive(Debug)]
pub enum PlayerCommand { pub enum PlayerCommand {
Play { url: String }, Play { url: String },
// Cast-only playback: decode to PCM and keep it available for cast taps,
// but do not open a CPAL output stream.
PlayCast { url: String },
Stop, Stop,
SetVolume { volume: f32 }, SetVolume { volume: f32 },
CastTapStart {
port: u16,
reply: mpsc::Sender<Result<(), String>>,
},
CastTapStop,
Shutdown, Shutdown,
} }
@@ -103,7 +126,7 @@ fn set_error(shared: &'static PlayerShared, message: String) {
s.error = Some(message); s.error = Some(message);
} }
fn ffmpeg_command() -> OsString { pub(crate) fn ffmpeg_command() -> OsString {
// Step 2: external ffmpeg binary. // Step 2: external ffmpeg binary.
// Lookup order: // Lookup order:
// 1) RADIOPLAYER_FFMPEG (absolute or relative) // 1) RADIOPLAYER_FFMPEG (absolute or relative)
@@ -139,19 +162,9 @@ fn ffmpeg_command() -> OsString {
OsString::from(local_name) OsString::from(local_name)
} }
pub fn preflight_check() -> Result<(), String> { pub fn preflight_ffmpeg_only() -> Result<(), String> {
// Ensure we have an output device up-front so UI gets a synchronous error.
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?;
let _ = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?;
// Ensure ffmpeg can be executed.
let ffmpeg = ffmpeg_command(); let ffmpeg = ffmpeg_command();
let status = Command::new(&ffmpeg) let status = command_hidden(&ffmpeg)
.arg("-version") .arg("-version")
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
@@ -165,24 +178,54 @@ pub fn preflight_check() -> Result<(), String> {
if !status.success() { if !status.success() {
return Err("FFmpeg exists but returned non-zero for -version".to_string()); return Err("FFmpeg exists but returned non-zero for -version".to_string());
} }
Ok(())
}
pub fn preflight_check() -> Result<(), String> {
// Ensure we have an output device up-front so UI gets a synchronous error.
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?;
let _ = device
.default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?;
preflight_ffmpeg_only()?;
Ok(()) Ok(())
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PipelineMode {
WithOutput,
Headless,
}
struct CastTapProc {
child: std::process::Child,
writer_join: Option<std::thread::JoinHandle<()>>,
}
struct Pipeline { struct Pipeline {
stop_flag: Arc<AtomicBool>, stop_flag: Arc<AtomicBool>,
volume_bits: Arc<AtomicU32>, volume_bits: Arc<AtomicU32>,
_stream: cpal::Stream, _stream: Option<cpal::Stream>,
decoder_join: Option<std::thread::JoinHandle<()>>, decoder_join: Option<std::thread::JoinHandle<()>>,
cast_tx: Arc<Mutex<Option<mpsc::SyncSender<Vec<u8>>>>>,
cast_proc: Option<CastTapProc>,
sample_rate: u32,
channels: u16,
} }
impl Pipeline { impl Pipeline {
fn start(shared: &'static PlayerShared, url: String) -> Result<Self, String> { fn start(shared: &'static PlayerShared, url: String, mode: PipelineMode) -> Result<Self, String> {
let (device, sample_format, cfg, sample_rate, channels) = match mode {
PipelineMode::WithOutput => {
let host = cpal::default_host(); let host = cpal::default_host();
let device = host let device = host
.default_output_device() .default_output_device()
.ok_or_else(|| "No default audio output device".to_string())?; .ok_or_else(|| "No default audio output device".to_string())?;
let default_cfg = device let default_cfg = device
.default_output_config() .default_output_config()
.map_err(|e| format!("Failed to get output config: {e}"))?; .map_err(|e| format!("Failed to get output config: {e}"))?;
@@ -190,13 +233,27 @@ impl Pipeline {
let cfg = default_cfg.config(); let cfg = default_cfg.config();
let sample_rate = cfg.sample_rate.0; let sample_rate = cfg.sample_rate.0;
let channels = cfg.channels as u16; let channels = cfg.channels as u16;
(Some(device), Some(sample_format), Some(cfg), sample_rate, channels)
}
PipelineMode::Headless => {
// For cast-only, pick a sane, widely-supported PCM format.
// This does not depend on an audio device.
(None, None, None, 48_000u32, 2u16)
}
};
// 5 seconds of PCM buffering (i16 samples) // 5 seconds of PCM buffering (i16 samples)
let (mut prod_opt, mut cons_opt) = if mode == PipelineMode::WithOutput {
let cfg = cfg.as_ref().expect("cfg must exist for WithOutput");
let capacity_samples = (sample_rate as usize) let capacity_samples = (sample_rate as usize)
.saturating_mul(cfg.channels as usize) .saturating_mul(cfg.channels as usize)
.saturating_mul(5); .saturating_mul(5);
let rb = HeapRb::<i16>::new(capacity_samples); let rb = HeapRb::<i16>::new(capacity_samples);
let (mut prod, mut cons) = rb.split(); let (prod, cons) = rb.split();
(Some(prod), Some(cons))
} else {
(None, None)
};
let stop_flag = Arc::new(AtomicBool::new(false)); let stop_flag = Arc::new(AtomicBool::new(false));
let volume_bits = Arc::new(AtomicU32::new({ let volume_bits = Arc::new(AtomicU32::new({
@@ -204,15 +261,18 @@ impl Pipeline {
volume_to_bits(s.volume) volume_to_bits(s.volume)
})); }));
let cast_tx: Arc<Mutex<Option<mpsc::SyncSender<Vec<u8>>>>> = Arc::new(Mutex::new(None));
// Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer. // Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer.
let stop_for_decoder = Arc::clone(&stop_flag); let stop_for_decoder = Arc::clone(&stop_flag);
let shared_for_decoder = shared; let shared_for_decoder = shared;
let decoder_url = url.clone(); let decoder_url = url.clone();
let cast_tx_for_decoder = Arc::clone(&cast_tx);
let decoder_join = std::thread::spawn(move || { let decoder_join = std::thread::spawn(move || {
let mut backoff_ms: u64 = 250; let mut backoff_ms: u64 = 250;
let mut pushed_since_start: usize = 0; let mut pushed_since_start: usize = 0;
let playing_threshold_samples = (sample_rate as usize) let playing_threshold_samples = (sample_rate as usize)
.saturating_mul(cfg.channels as usize) .saturating_mul(channels as usize)
.saturating_div(4); // ~250ms .saturating_div(4); // ~250ms
'outer: loop { 'outer: loop {
@@ -224,7 +284,7 @@ impl Pipeline {
let ffmpeg = ffmpeg_command(); let ffmpeg = ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy(); let ffmpeg_disp = ffmpeg.to_string_lossy();
let mut child = match Command::new(&ffmpeg) let mut child = match command_hidden(&ffmpeg)
.arg("-nostdin") .arg("-nostdin")
.arg("-hide_banner") .arg("-hide_banner")
.arg("-loglevel") .arg("-loglevel")
@@ -303,13 +363,21 @@ impl Pipeline {
backoff_ms = 250; backoff_ms = 250;
// Forward raw PCM bytes to cast tap (if enabled).
if let Some(tx) = cast_tx_for_decoder.lock().unwrap().as_ref() {
// Best-effort: never block local playback.
let _ = tx.try_send(buf[..n].to_vec());
}
// Convert bytes to i16 LE samples // Convert bytes to i16 LE samples
let mut i = 0usize; let mut i = 0usize;
if let Some(b0) = leftover.take() { if let Some(b0) = leftover.take() {
if n >= 1 { if n >= 1 {
let b1 = buf[0]; let b1 = buf[0];
let sample = i16::from_le_bytes([b0, b1]); let sample = i16::from_le_bytes([b0, b1]);
if let Some(prod) = prod_opt.as_mut() {
let _ = prod.push(sample); let _ = prod.push(sample);
}
pushed_since_start += 1; pushed_since_start += 1;
i = 1; i = 1;
} else { } else {
@@ -319,9 +387,10 @@ impl Pipeline {
while i + 1 < n { while i + 1 < n {
let sample = i16::from_le_bytes([buf[i], buf[i + 1]]); let sample = i16::from_le_bytes([buf[i], buf[i + 1]]);
if prod.push(sample).is_ok() { if let Some(prod) = prod_opt.as_mut() {
pushed_since_start += 1; let _ = prod.push(sample);
} }
pushed_since_start += 1;
i += 2; i += 2;
} }
@@ -337,6 +406,12 @@ impl Pipeline {
} }
}); });
let stream = if mode == PipelineMode::WithOutput {
let device = device.expect("device must exist for WithOutput");
let sample_format = sample_format.expect("sample_format must exist for WithOutput");
let cfg = cfg.expect("cfg must exist for WithOutput");
let mut cons = cons_opt.take().expect("cons must exist for WithOutput");
// Audio callback: drain ring buffer and write to output. // Audio callback: drain ring buffer and write to output.
let shared_for_cb = shared; let shared_for_cb = shared;
let stop_for_cb = Arc::clone(&stop_flag); let stop_for_cb = Arc::clone(&stop_flag);
@@ -349,7 +424,7 @@ impl Pipeline {
set_error(shared_for_cb, msg); set_error(shared_for_cb, msg);
}; };
let stream = match sample_format { let built = match sample_format {
cpal::SampleFormat::F32 => device.build_output_stream( cpal::SampleFormat::F32 => device.build_output_stream(
&cfg, &cfg,
move |data: &mut [f32], _| { move |data: &mut [f32], _| {
@@ -399,7 +474,8 @@ impl Pipeline {
let mut underrun = false; let mut underrun = false;
for s in data.iter_mut() { for s in data.iter_mut() {
if let Some(v) = cons.pop() { if let Some(v) = cons.pop() {
let scaled = (v as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32); let scaled =
(v as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32);
*s = scaled as i16; *s = scaled as i16;
} else { } else {
*s = 0; *s = 0;
@@ -463,20 +539,123 @@ impl Pipeline {
} }
.map_err(|e| format!("Failed to create output stream: {e}"))?; .map_err(|e| format!("Failed to create output stream: {e}"))?;
stream built
.play() .play()
.map_err(|e| format!("Failed to start output stream: {e}"))?; .map_err(|e| format!("Failed to start output stream: {e}"))?;
Some(built)
} else {
None
};
Ok(Self { Ok(Self {
stop_flag, stop_flag,
volume_bits, volume_bits,
_stream: stream, _stream: stream,
decoder_join: Some(decoder_join), decoder_join: Some(decoder_join),
cast_tx,
cast_proc: None,
sample_rate,
channels,
}) })
} }
fn start_cast_tap(&mut self, port: u16, sample_rate: u32, channels: u16) -> Result<(), String> {
// Stop existing tap first.
self.stop_cast_tap();
let ffmpeg = ffmpeg_command();
let ffmpeg_disp = ffmpeg.to_string_lossy();
let spawn = |codec: &str| -> Result<std::process::Child, String> {
command_hidden(&ffmpeg)
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
.arg("warning")
.arg("-f")
.arg("s16le")
.arg("-ac")
.arg(channels.to_string())
.arg("-ar")
.arg(sample_rate.to_string())
.arg("-i")
.arg("pipe:0")
.arg("-vn")
.arg("-c:a")
.arg(codec)
.arg("-b:a")
.arg("128k")
.arg("-f")
.arg("mp3")
.arg("-content_type")
.arg("audio/mpeg")
.arg("-listen")
.arg("1")
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
format!(
"Failed to start ffmpeg cast tap ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
)
})
};
let mut child = spawn("libmp3lame")?;
std::thread::sleep(Duration::from_millis(150));
if let Ok(Some(status)) = child.try_wait() {
if !status.success() {
// Some builds lack libmp3lame; fall back to built-in encoder.
child = spawn("mp3")?;
}
}
let stdin = child
.stdin
.take()
.ok_or_else(|| "ffmpeg cast tap stdin not available".to_string())?;
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256);
*self.cast_tx.lock().unwrap() = Some(tx);
let writer_join = std::thread::spawn(move || {
use std::io::Write;
let mut stdin = stdin;
while let Ok(chunk) = rx.recv() {
if chunk.is_empty() {
continue;
}
if stdin.write_all(&chunk).is_err() {
break;
}
}
let _ = stdin.flush();
});
self.cast_proc = Some(CastTapProc {
child,
writer_join: Some(writer_join),
});
Ok(())
}
fn stop_cast_tap(&mut self) {
*self.cast_tx.lock().unwrap() = None;
if let Some(mut proc) = self.cast_proc.take() {
let _ = proc.child.kill();
let _ = proc.child.wait();
if let Some(j) = proc.writer_join.take() {
let _ = j.join();
}
}
}
fn stop(mut self, shared: &'static PlayerShared) { fn stop(mut self, shared: &'static PlayerShared) {
self.stop_flag.store(true, Ordering::SeqCst); self.stop_flag.store(true, Ordering::SeqCst);
self.stop_cast_tap();
// dropping stream stops audio // dropping stream stops audio
if let Some(j) = self.decoder_join.take() { if let Some(j) = self.decoder_join.take() {
let _ = j.join(); let _ = j.join();
@@ -492,6 +671,7 @@ impl Pipeline {
fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand>) { fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand>) {
// Step 2: FFmpeg decode + CPAL playback. // Step 2: FFmpeg decode + CPAL playback.
let mut pipeline: Option<Pipeline> = None; let mut pipeline: Option<Pipeline> = None;
let mut pipeline_cast_owned = false;
while let Ok(cmd) = rx.recv() { while let Ok(cmd) = rx.recv() {
match cmd { match cmd {
PlayerCommand::Play { url } => { PlayerCommand::Play { url } => {
@@ -499,6 +679,8 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
p.stop(shared); p.stop(shared);
} }
pipeline_cast_owned = false;
{ {
let mut s = shared.state.lock().unwrap(); let mut s = shared.state.lock().unwrap();
s.error = None; s.error = None;
@@ -506,7 +688,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
s.status = PlayerStatus::Buffering; s.status = PlayerStatus::Buffering;
} }
match Pipeline::start(shared, url) { match Pipeline::start(shared, url, PipelineMode::WithOutput) {
Ok(p) => { Ok(p) => {
// Apply current volume to pipeline atomics. // Apply current volume to pipeline atomics.
let vol = { shared.state.lock().unwrap().volume }; let vol = { shared.state.lock().unwrap().volume };
@@ -519,6 +701,32 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
} }
} }
} }
PlayerCommand::PlayCast { url } => {
if let Some(p) = pipeline.take() {
p.stop(shared);
}
pipeline_cast_owned = true;
{
let mut s = shared.state.lock().unwrap();
s.error = None;
s.url = Some(url.clone());
s.status = PlayerStatus::Buffering;
}
match Pipeline::start(shared, url, PipelineMode::Headless) {
Ok(p) => {
let vol = { shared.state.lock().unwrap().volume };
p.set_volume(vol);
pipeline = Some(p);
}
Err(e) => {
set_error(shared, e);
pipeline = None;
}
}
}
PlayerCommand::Stop => { PlayerCommand::Stop => {
if let Some(p) = pipeline.take() { if let Some(p) = pipeline.take() {
p.stop(shared); p.stop(shared);
@@ -527,6 +735,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
s.status = PlayerStatus::Stopped; s.status = PlayerStatus::Stopped;
s.error = None; s.error = None;
} }
pipeline_cast_owned = false;
} }
PlayerCommand::SetVolume { volume } => { PlayerCommand::SetVolume { volume } => {
let v = clamp01(volume); let v = clamp01(volume);
@@ -538,6 +747,26 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand
p.set_volume(v); p.set_volume(v);
} }
} }
PlayerCommand::CastTapStart { port, reply } => {
if let Some(p) = pipeline.as_mut() {
// Current pipeline sample format is always s16le.
let res = p.start_cast_tap(port, p.sample_rate, p.channels);
let _ = reply.send(res);
} else {
let _ = reply.send(Err("No active decoder pipeline".to_string()));
}
}
PlayerCommand::CastTapStop => {
if let Some(p) = pipeline.as_mut() {
p.stop_cast_tap();
}
if pipeline_cast_owned {
if let Some(p) = pipeline.take() {
p.stop(shared);
}
pipeline_cast_owned = false;
}
}
PlayerCommand::Shutdown => break, PlayerCommand::Shutdown => break,
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "RadioPlayer", "productName": "RadioPlayer",
"version": "0.1.0", "version": "0.1.1",
"identifier": "si.klevze.radioPlayer", "identifier": "si.klevze.radioPlayer",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"

View File

@@ -11,6 +11,7 @@ let currentIndex = 0;
let isPlaying = false; let isPlaying = false;
let currentMode = 'local'; // 'local' | 'cast' let currentMode = 'local'; // 'local' | 'cast'
let currentCastDevice = null; let currentCastDevice = null;
let currentCastTransport = null; // 'tap' | 'proxy' | 'direct' | null
// Local playback is handled natively by the Tauri backend (player_* commands). // Local playback is handled natively by the Tauri backend (player_* commands).
// The WebView is a control surface only. // The WebView is a control surface only.
@@ -158,12 +159,63 @@ const usIndex = document.getElementById('us_index');
// Init // Init
async function init() { async function init() {
try { try {
// Helpful debug information for release builds so we can compare parity with dev.
console.group && console.group('RadioCast init');
console.log('runningInTauri:', runningInTauri);
try { console.log('location:', location.href); } catch (_) {}
try { console.log('userAgent:', navigator.userAgent); } catch (_) {}
try { console.log('platform:', navigator.platform); } catch (_) {}
try { console.log('RADIO_DEBUG_DEVTOOLS flag:', localStorage.getItem('RADIO_DEBUG_DEVTOOLS')); } catch (_) {}
// Always try to read build stamp if present (bundled by build scripts).
try {
const resp = await fetch('/build-info.json', { cache: 'no-store' });
if (resp && resp.ok) {
const bi = await resp.json();
console.log('build-info:', bi);
} else {
console.log('build-info: not present');
}
} catch (e) {
console.log('build-info: failed to read');
}
restoreSavedVolume(); restoreSavedVolume();
await loadStations(); await loadStations();
try { console.log('stations loaded:', Array.isArray(stations) ? stations.length : typeof stations); } catch (_) {}
setupEventListeners(); setupEventListeners();
ensureArtworkPointerFallback(); ensureArtworkPointerFallback();
updateUI(); updateUI();
updateEngineBadge(); updateEngineBadge();
// Optionally open devtools in release builds for debugging parity with `tauri dev`.
// Enable by setting `localStorage.setItem('RADIO_DEBUG_DEVTOOLS', '1')` or by creating
// `src/build-info.json` with { debug: true } at build time (the `build:devlike` script does this).
try {
let shouldOpen = false;
try { if (localStorage && localStorage.getItem && localStorage.getItem('RADIO_DEBUG_DEVTOOLS') === '1') shouldOpen = true; } catch (_) {}
// Build-time flag file (created by tools/write-build-flag.js when running `build`/`build:devlike`).
try {
const resp = await fetch('/build-info.json', { cache: 'no-store' });
if (resp && resp.ok) {
const bi = await resp.json();
if (bi && bi.debug) shouldOpen = true;
}
} catch (_) {}
if (shouldOpen) {
try {
const w = getCurrentWindow();
if (w && typeof w.openDevTools === 'function') {
w.openDevTools();
console.log('Opened devtools via build-info/localStorage flag');
}
} catch (e) { console.warn('Failed to open devtools:', e); }
}
} catch (e) { /* ignore */ }
console.groupEnd && console.groupEnd();
} catch (e) { } catch (e) {
console.error('Error during init', e); console.error('Error during init', e);
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e)); if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e));
@@ -1161,7 +1213,39 @@ async function play() {
} else if (currentMode === 'cast' && currentCastDevice) { } else if (currentMode === 'cast' && currentCastDevice) {
// Cast logic // Cast logic
try { try {
await invoke('cast_play', { deviceName: currentCastDevice, url: station.url }); // UX guard: if native playback is currently decoding a different station,
// stop it explicitly before starting the cast pipeline (which would otherwise
// replace the decoder behind the scenes).
try {
const st = await invoke('player_get_state');
const nativeActive = st && (st.status === 'playing' || st.status === 'buffering') && st.url;
if (nativeActive && st.url !== station.url) {
stopLocalPlayerStatePolling();
await invoke('player_stop').catch(() => {});
}
} catch (_) {
// Ignore: best-effort guard only.
}
let castUrl = station.url;
currentCastTransport = null;
try {
const res = await invoke('cast_proxy_start', { deviceName: currentCastDevice, url: station.url });
if (res && typeof res === 'object') {
castUrl = res.url || station.url;
currentCastTransport = res.mode || 'proxy';
} else {
// Backward-compat (older backend returned string)
castUrl = res || station.url;
currentCastTransport = 'proxy';
}
} catch (e) {
// If proxy cannot start (ffmpeg missing, firewall, etc), fall back to direct station URL.
console.warn('Cast proxy start failed; falling back to direct URL', e);
currentCastTransport = 'direct';
}
await invoke('cast_play', { deviceName: currentCastDevice, url: castUrl });
isPlaying = true; isPlaying = true;
// Sync volume // Sync volume
const vol = volumeSlider.value / 100; const vol = volumeSlider.value / 100;
@@ -1169,8 +1253,10 @@ async function play() {
updateUI(); updateUI();
} catch (e) { } catch (e) {
console.error('Cast failed', e); console.error('Cast failed', e);
statusTextEl.textContent = 'Cast Error'; statusTextEl.textContent = 'Cast Error (check LAN/firewall)';
await invoke('cast_proxy_stop').catch(() => {});
currentMode = 'local'; // Fallback currentMode = 'local'; // Fallback
currentCastTransport = null;
updateUI(); updateUI();
} }
} }
@@ -1186,6 +1272,7 @@ async function stop() {
} }
} else if (currentMode === 'cast' && currentCastDevice) { } else if (currentMode === 'cast' && currentCastDevice) {
try { try {
await invoke('cast_proxy_stop').catch(() => {});
await invoke('cast_stop', { deviceName: currentCastDevice }); await invoke('cast_stop', { deviceName: currentCastDevice });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -1193,6 +1280,9 @@ async function stop() {
} }
isPlaying = false; isPlaying = false;
if (currentMode !== 'cast') {
currentCastTransport = null;
}
updateUI(); updateUI();
} }
@@ -1220,14 +1310,24 @@ function updateUI() {
playBtn.classList.add('playing'); // Add pulsing ring animation playBtn.classList.add('playing'); // Add pulsing ring animation
statusTextEl.textContent = 'Playing'; statusTextEl.textContent = 'Playing';
statusDotEl.style.backgroundColor = 'var(--success)'; statusDotEl.style.backgroundColor = 'var(--success)';
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream'; if (currentMode === 'cast') {
const t = currentCastTransport ? ` (${currentCastTransport})` : '';
stationSubtitleEl.textContent = `Casting${t} to ${currentCastDevice}`;
} else {
stationSubtitleEl.textContent = 'Live Stream';
}
} else { } else {
iconPlay.classList.remove('hidden'); iconPlay.classList.remove('hidden');
iconStop.classList.add('hidden'); iconStop.classList.add('hidden');
playBtn.classList.remove('playing'); // Remove pulsing ring playBtn.classList.remove('playing'); // Remove pulsing ring
statusTextEl.textContent = 'Ready'; statusTextEl.textContent = 'Ready';
statusDotEl.style.backgroundColor = 'var(--text-muted)'; statusDotEl.style.backgroundColor = 'var(--text-muted)';
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream'; if (currentMode === 'cast') {
const t = currentCastTransport ? ` (${currentCastTransport})` : '';
stationSubtitleEl.textContent = `Connected${t} to ${currentCastDevice}`;
} else {
stationSubtitleEl.textContent = 'Live Stream';
}
} }
updateEngineBadge(); updateEngineBadge();
@@ -1296,14 +1396,20 @@ async function selectCastDevice(deviceName) {
await stop(); await stop();
} }
// Best-effort cleanup: stop any lingering cast transport when changing device/mode.
await invoke('cast_proxy_stop').catch(() => {});
if (deviceName) { if (deviceName) {
currentMode = 'cast'; currentMode = 'cast';
currentCastDevice = deviceName; currentCastDevice = deviceName;
castBtn.style.color = 'var(--success)'; castBtn.style.color = 'var(--success)';
// Transport mode gets set on play.
currentCastTransport = currentCastTransport || null;
} else { } else {
currentMode = 'local'; currentMode = 'local';
currentCastDevice = null; currentCastDevice = null;
castBtn.style.color = 'var(--text-main)'; castBtn.style.color = 'var(--text-main)';
currentCastTransport = null;
} }
updateUI(); updateUI();
@@ -1313,22 +1419,51 @@ async function selectCastDevice(deviceName) {
// Let's prompt user to play. // Let's prompt user to play.
} }
// Best-effort: stop any cast transport when leaving the window.
window.addEventListener('beforeunload', () => {
try { invoke('cast_proxy_stop'); } catch (_) {}
});
window.addEventListener('DOMContentLoaded', init); window.addEventListener('DOMContentLoaded', init);
// Service worker is useful for the PWA, but it can cause confusing caching during // Service worker is useful for the PWA, but it can cause confusing caching during
// Tauri development because it may serve an older cached `index.html`. // Tauri development because it may serve an older cached `index.html`.
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
if (runningInTauri) { if (runningInTauri) {
// Best-effort cleanup so the desktop app always reflects local file changes. // Best-effort cleanup so the desktop app doesn't get stuck on an old cached UI.
navigator.serviceWorker.getRegistrations() // If we clear anything, do a one-time reload to ensure the new bundled assets are used.
.then((regs) => Promise.all(regs.map((r) => r.unregister()))) (async () => {
.catch(() => {}); let changed = false;
try {
const regs = await navigator.serviceWorker.getRegistrations();
if (regs && regs.length) {
await Promise.all(regs.map((r) => r.unregister().catch(() => false)));
changed = true;
}
} catch (_) {}
if ('caches' in window) { if ('caches' in window) {
caches.keys() try {
.then((keys) => Promise.all(keys.map((k) => caches.delete(k)))) const keys = await caches.keys();
.catch(() => {}); if (keys && keys.length) {
await Promise.all(keys.map((k) => caches.delete(k).catch(() => false)));
changed = true;
} }
} catch (_) {}
}
try {
if (changed) {
const k = '__radiocast_sw_cleared_once';
const already = sessionStorage.getItem(k);
if (!already) {
sessionStorage.setItem(k, '1');
location.reload();
}
}
} catch (_) {}
})();
} else { } else {
// Register Service Worker for PWA installation (non-disruptive) // Register Service Worker for PWA installation (non-disruptive)
window.addEventListener('load', () => { window.addEventListener('load', () => {
@@ -1400,7 +1535,9 @@ async function openStationsOverlay() {
li.onclick = async () => { li.onclick = async () => {
currentMode = 'local'; currentMode = 'local';
currentCastDevice = null; currentCastDevice = null;
currentCastTransport = null;
castBtn.style.color = 'var(--text-main)'; castBtn.style.color = 'var(--text-main)';
try { await invoke('cast_proxy_stop'); } catch (_) {}
await setStationByIndex(idx); await setStationByIndex(idx);
closeCastOverlay(); closeCastOverlay();
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }

View File

@@ -1,4 +1,10 @@
const CACHE_NAME = 'radiocast-core-v2'; // NOTE: This service worker is for the web/PWA build.
// For the Tauri desktop app we aggressively unregister SWs in `src/main.js`.
//
// Bump this value whenever caching logic changes to guarantee clients don't
// keep an old UI after updates.
const CACHE_NAME = 'radiocast-core-v3';
const CORE_ASSETS = [ const CORE_ASSETS = [
'.', '.',
'index.html', 'index.html',
@@ -7,14 +13,25 @@ const CORE_ASSETS = [
'stations.json', 'stations.json',
'assets/favicon_io/android-chrome-192x192.png', 'assets/favicon_io/android-chrome-192x192.png',
'assets/favicon_io/android-chrome-512x512.png', 'assets/favicon_io/android-chrome-512x512.png',
'assets/favicon_io/apple-touch-icon.png' 'assets/favicon_io/apple-touch-icon.png',
// Optional build stamp (only present for some builds).
'build-info.json',
]; ];
const CORE_PATHS = new Set(CORE_ASSETS.map((p) => (p === '.' ? '/' : '/' + p.replace(/^\//, ''))));
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
// Activate updated SW as soon as it's installed. // Activate updated SW as soon as it's installed.
self.skipWaiting(); self.skipWaiting();
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS)) caches.open(CACHE_NAME).then((cache) => {
const reqs = CORE_ASSETS.map((p) => {
const url = p === '.' ? './' : p;
// Force a fresh fetch for core assets to avoid carrying forward stale UI.
return new Request(url, { cache: 'reload' });
});
return cache.addAll(reqs);
})
); );
}); });
@@ -33,6 +50,30 @@ self.addEventListener('fetch', (event) => {
// Only handle GET requests // Only handle GET requests
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Don't cache cross-origin requests (station logos, APIs, etc.).
if (url.origin !== self.location.origin) {
return;
}
const isCore = CORE_PATHS.has(url.pathname) || url.pathname === '/';
const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html');
// Network-first for navigations and core assets to prevent "old UI" issues.
if (isHtmlNavigation || isCore) {
event.respondWith(
fetch(event.request)
.then((networkResp) => {
const respClone = networkResp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(() => {});
return networkResp;
})
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('index.html')))
);
return;
}
event.respondWith( event.respondWith(
caches.match(event.request).then((cached) => { caches.match(event.request).then((cached) => {
if (cached) return cached; if (cached) return cached;

60
tools/sync-version.js Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const repoRoot = process.cwd();
function readJson(p) {
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function writeJson(p, obj) {
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}
function updateCargoTomlVersion(cargoTomlPath, version) {
const input = fs.readFileSync(cargoTomlPath, 'utf8');
// Replace only the [package] version line.
const packageBlockStart = input.indexOf('[package]');
if (packageBlockStart === -1) {
throw new Error('Could not find [package] in Cargo.toml');
}
const packageBlockEnd = input.indexOf('\n[', packageBlockStart + 1);
const blockEnd = packageBlockEnd === -1 ? input.length : packageBlockEnd;
const pkgBlock = input.slice(packageBlockStart, blockEnd);
const versionRe = /^version\s*=\s*"([^"]*)"/m;
const m = pkgBlock.match(versionRe);
if (!m) {
throw new Error('Could not find version line in Cargo.toml [package] block');
}
const replaced = pkgBlock.replace(versionRe, `version = "${version}"`);
const output = input.slice(0, packageBlockStart) + replaced + input.slice(blockEnd);
fs.writeFileSync(cargoTomlPath, output, 'utf8');
}
try {
const rootPkgPath = path.join(repoRoot, 'package.json');
const tauriConfPath = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
const cargoTomlPath = path.join(repoRoot, 'src-tauri', 'Cargo.toml');
const rootPkg = readJson(rootPkgPath);
if (!rootPkg.version) throw new Error('Root package.json has no version');
const version = String(rootPkg.version);
const tauriConf = readJson(tauriConfPath);
tauriConf.version = version;
writeJson(tauriConfPath, tauriConf);
updateCargoTomlVersion(cargoTomlPath, version);
console.log(`Synced Tauri version to ${version}`);
} catch (e) {
console.error('sync-version failed:', e?.message || e);
process.exit(1);
}

54
tools/write-build-flag.js Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const cmd = process.argv[2] || 'set';
const repoRoot = process.cwd();
const dst = path.join(repoRoot, 'src', 'build-info.json');
function getPackageVersion() {
try {
const pkgPath = path.join(repoRoot, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return pkg && pkg.version ? String(pkg.version) : null;
} catch (_) {
return null;
}
}
function computeDebugFlag() {
const envVal = process.env.RADIO_DEBUG_DEVTOOLS;
if (envVal === '1' || envVal === 'true') return true;
const arg = (process.argv[3] || '').toLowerCase();
return arg === 'debug' || arg === '--debug';
}
if (cmd === 'set') {
try {
const version = getPackageVersion();
const debug = computeDebugFlag();
const payload = {
version,
debug,
builtAt: new Date().toISOString(),
};
fs.writeFileSync(dst, JSON.stringify(payload, null, 2) + '\n', 'utf8');
console.log(`Wrote build-info.json (debug=${debug}${version ? `, version=${version}` : ''})`);
process.exit(0);
} catch (e) {
console.error('Failed to write build-info.json', e);
process.exit(1);
}
} else if (cmd === 'clear') {
try {
if (fs.existsSync(dst)) fs.unlinkSync(dst);
console.log('Removed build-info.json');
process.exit(0);
} catch (e) {
console.error('Failed to remove build-info.json', e);
process.exit(1);
}
} else {
console.error('Unknown command:', cmd);
process.exit(2);
}

View File

@@ -1,19 +0,0 @@
# 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

@@ -1 +0,0 @@
{"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"}

View File

@@ -1,218 +0,0 @@
<!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>

View File

@@ -1,22 +0,0 @@
{
"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
View File

@@ -1,942 +0,0 @@
{
"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
}
}
}
}
}

View File

@@ -1,14 +0,0 @@
{
"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"
}
}

View File

@@ -1,20 +0,0 @@
// 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)');
});

View File

@@ -1,800 +0,0 @@
[
{
"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,

View File

@@ -1,886 +0,0 @@
/* 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;
}

View File

@@ -1,48 +0,0 @@
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

@@ -1,13 +0,0 @@
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, '..')]
}
}
});