diff --git a/.ai/tauris-agent.md b/.ai/tauris-agent.md index f021f3e..53800ab 100644 --- a/.ai/tauris-agent.md +++ b/.ai/tauris-agent.md @@ -1,6 +1,7 @@ # ROLE: Senior Desktop Audio Engineer & Tauri Architect You are an expert in: + - Tauri (Rust backend + system WebView frontend) - Native audio streaming (FFmpeg, GStreamer, CPAL, Rodio) - 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. -### Current architecture: +### Current architecture + - Frontend: Vanilla HTML / CSS / JS served in WebView - 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`) - Stations: JSON file + user-defined stations in `localStorage` - Platforms: Windows, Linux, macOS -### Critical limitation: -HTML5 audio is insufficient for: +### Critical limitation + +Browser/HTML5 audio is insufficient for: + - stable radio streaming - buffering control - reconnection @@ -52,22 +56,26 @@ This is an **incremental upgrade**, not a rewrite. - UI remains WebView-based (HTML/CSS/JS) - JS communicates only via Tauri `invoke()` - Audio decoding and playback are handled natively -- One decoded stream can be routed to: - - local speakers - - cast devices +- Local playback: FFmpeg decodes to PCM and CPAL outputs to 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 +- 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 +Note: “Reuse decoded audio” here means: one FFmpeg decode → PCM → fan-out to CPAL (local) and FFmpeg encode/listen (cast). + --- ## TECHNICAL DIRECTIVES (MANDATORY) ### 1. Frontend rules + - DO NOT redesign HTML or CSS - 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 ### 2. Backend rules + - Prefer **Rust-native solutions** - Acceptable audio stacks: - FFmpeg + CPAL / Rodio @@ -84,8 +92,9 @@ This is an **incremental upgrade**, not a rewrite. - thread safety ### 3. Casting rules + - 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 - Sidecar removal is OPTIONAL, not required now @@ -94,12 +103,14 @@ This is an **incremental upgrade**, not a rewrite. ## MIGRATION STRATEGY (VERY IMPORTANT) You must: + - Work in **small, safe steps** - Clearly explain what files change and why - Never delete working functionality without replacement - Prefer additive refactors over destructive ones Each response should: + 1. Explain intent 2. Show concrete code 3. State which file is modified @@ -110,6 +121,7 @@ Each response should: ## WHAT YOU SHOULD PRODUCE You may generate: + - Rust code (Tauri commands, audio engine) - JS changes (invoke-based playback) - Architecture explanations @@ -118,6 +130,7 @@ You may generate: - Warnings about pitfalls You MUST NOT: + - Suggest Electron or Flutter - Suggest full rewrites - Ignore existing sidecar or station model @@ -148,6 +161,7 @@ The WebView is a **control surface**, not a media engine. ## FIRST TASK WHEN STARTING Begin by: + 1. Identifying all HTML5 Audio usage 2. Proposing the native audio engine design 3. Defining the minimal command interface diff --git a/README.md b/README.md index 37e6baa..5649ea2 100644 --- a/README.md +++ b/README.md @@ -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): ```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: @@ -50,7 +56,7 @@ To create an optimized, standalone executable for your operating system: 1. **Run the build command**: ```bash - npm run tauri build + npm run build ``` 2. **Locate the artifacts**: @@ -67,7 +73,9 @@ To create an optimized, standalone executable for your operating system: * `styles.css`: Application styling. * `stations.json`: Configuration file for available radio streams. * **`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). ## Customization diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md index 4dede14..0e7c44d 100644 --- a/TECHNICAL_DOCUMENTATION.md +++ b/TECHNICAL_DOCUMENTATION.md @@ -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) - **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) - **Packaging utilities**: - Sidecar binary copy/rename step: [tools/copy-binaries.js](tools/copy-binaries.js) - Windows EXE icon patch: [tools/post-build-rcedit.js](tools/post-build-rcedit.js) + - Optional FFmpeg bundling helper: [tools/copy-ffmpeg.js](tools/copy-ffmpeg.js) (see [tools/ffmpeg/README.md](tools/ffmpeg/README.md)) Data flow: 1. UI actions call JS functions in `main.js`. -2. When in **Cast mode**, JS calls Tauri commands via `window.__TAURI__.core.invoke()`. -3. The Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`. -4. On `cast_play/stop/volume`, Rust spawns (or reuses) a **sidecar process**, then sends newline-delimited JSON commands to the sidecar stdin. +2. JS calls Tauri commands via `window.__TAURI__.core.invoke()` (for both local playback and casting). +3. In **Local mode**, Rust spawns FFmpeg and plays decoded PCM via CPAL. +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 @@ -113,6 +116,29 @@ When a device is resolved: ### 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` + +- Returns `{ status, url, volume, error }`. +- Used by the UI to keep status text and play/stop button in sync. + #### `list_cast_devices() -> Result, String>` - Returns the sorted list of discovered Cast device names. @@ -214,7 +240,8 @@ State is tracked in JS: #### 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 @@ -274,7 +301,7 @@ Note: - `#play-btn` - 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')`. - `#prev-btn` - Previous station (`playPrev()` → `setStationByIndex()`). @@ -284,7 +311,7 @@ Note: ### Volume - `#volume-slider` - - Local: sets `audio.volume`. + - Local: `invoke('player_set_volume')`. - Cast: `invoke('cast_set_volume')`. - Persists `localStorage.volume`. - `#mute-btn` diff --git a/android/README.md b/android/README.md deleted file mode 100644 index 5e361fc..0000000 --- a/android/README.md +++ /dev/null @@ -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. diff --git a/android/app/src/main/assets/assets/appIcon.png b/android/app/src/main/assets/assets/appIcon.png deleted file mode 100644 index d789348..0000000 Binary files a/android/app/src/main/assets/assets/appIcon.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io.zip b/android/app/src/main/assets/assets/favicon_io.zip deleted file mode 100644 index 15adffb..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io.zip and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/android-chrome-192x192.png b/android/app/src/main/assets/assets/favicon_io/android-chrome-192x192.png deleted file mode 100644 index 4757781..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/android-chrome-192x192.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/android-chrome-512x512.png b/android/app/src/main/assets/assets/favicon_io/android-chrome-512x512.png deleted file mode 100644 index 8f1299a..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/android-chrome-512x512.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/app-icon.png b/android/app/src/main/assets/assets/favicon_io/app-icon.png deleted file mode 100644 index 8f1299a..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/app-icon.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/apple-touch-icon.png b/android/app/src/main/assets/assets/favicon_io/apple-touch-icon.png deleted file mode 100644 index f29ebcf..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/apple-touch-icon.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/favicon-16x16.png b/android/app/src/main/assets/assets/favicon_io/favicon-16x16.png deleted file mode 100644 index 4eb99da..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/favicon-16x16.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/favicon-32x32.png b/android/app/src/main/assets/assets/favicon_io/favicon-32x32.png deleted file mode 100644 index 73c330b..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/favicon-32x32.png and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/icon.ico b/android/app/src/main/assets/assets/favicon_io/icon.ico deleted file mode 100644 index 586e969..0000000 Binary files a/android/app/src/main/assets/assets/favicon_io/icon.ico and /dev/null differ diff --git a/android/app/src/main/assets/assets/favicon_io/site.webmanifest b/android/app/src/main/assets/assets/favicon_io/site.webmanifest deleted file mode 100644 index 45dc8a2..0000000 --- a/android/app/src/main/assets/assets/favicon_io/site.webmanifest +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/android/app/src/main/assets/assets/javascript.svg b/android/app/src/main/assets/assets/javascript.svg deleted file mode 100644 index f9abb2b..0000000 --- a/android/app/src/main/assets/assets/javascript.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/android/app/src/main/assets/assets/tauri.svg b/android/app/src/main/assets/assets/tauri.svg deleted file mode 100644 index 0c0e6aa..0000000 --- a/android/app/src/main/assets/assets/tauri.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/android/app/src/main/assets/index.html b/android/app/src/main/assets/index.html deleted file mode 100644 index 822cfaa..0000000 --- a/android/app/src/main/assets/index.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - - Radio Player - - - - - -
-
-
- -
-
- -
- Radio1 Player - - - Ready - FFMPEG - -
-
- - -
-
- -
-
-
- - - - - - - - - - - - - - - - 1 -
-
-
- -
-

-

-
- - -
-
-
-
-
-
- -
- - - - - -
- -
- -
- -
- 50% -
- - - - -
-
- - - \ No newline at end of file diff --git a/android/app/src/main/assets/main.js b/android/app/src/main/assets/main.js deleted file mode 100644 index 116e77e..0000000 --- a/android/app/src/main/assets/main.js +++ /dev/null @@ -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' - ? `` - : ``; - - engineBadgeEl.innerHTML = `${iconSvg}${label}`; - 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 = '
  • Scanning...
    Searching for speakers
  • '; - - 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 = '
    This Computer
    Local Playback
    '; - 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 = `
    ${d}
    Google Cast Speaker
    `; - li.onclick = () => selectCastDevice(d); - deviceListEl.appendChild(li); - }); - } - } catch (e) { - deviceListEl.innerHTML = `
  • Error
    ${e}
  • `; - } -} - -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 = '
  • Loading...
    Preparing stations
  • '; - - // If stations not loaded yet, show message - if (!stations || stations.length === 0) { - deviceListEl.innerHTML = '
  • No stations found
    Check your stations.json
  • '; - 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 = `
    ${s.name}
    ${subtitle}
    `; - 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); - }); -} diff --git a/android/app/src/main/assets/stations.json b/android/app/src/main/assets/stations.json deleted file mode 100644 index 51086d7..0000000 --- a/android/app/src/main/assets/stations.json +++ /dev/null @@ -1,1342 +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": "https://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://www.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, - "poster": null, - "lastSongs": "https://data.radio.si/api/lastsongsxml/radio1rock/json", - "epg": "", - "defaultText": "www.rockmaribor.si", - "www": "https://rockmaribor.si/", - "mountPoints": [ - "RockMB" - ], - "social": [ - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://rockmaribor.si/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", - "link": "https://www.facebook.com/RockMaribor.si" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", - "link": "https://www.youtube.com/channel/UC99aNwZXokG6nnJLfSn5DSw" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", - "link": "https://www.facebook.com/radiocelje" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", - "link": "https://www.instagram.com/radiocelje/" - } - ], - "enabled": true, - "radioApiIO": "https://onair.radioapi.io/ingest/infonet/rockmaribor?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61116", - "rpUid": "705107", - "dabUser": "celje", - "dabPass": "dunk7815g", - "dabDefaultImg": "http://media.radio.si/logo/dns/celje/RadioCelje_DAB.jpg", - "small": false - }, - { - "id": "Kranj", - "title": "Radio Kranj", - "slogan": "", - "logo": "https://data.radio.si/api/radiostations/logo/kranj.svg", - "liveAudio": "http://live.radio.si/Kranj", - "liveVideo": null, - "poster": null, - "lastSongs": "https://data.radio.si/api/lastsongsxml/kranj/json", - "epg": "http://spored.radio.si/api/now/kranj", - "defaultText": "www.radio-kranj.si", - "www": "https://radio-kranj.si/", - "mountPoints": [ - "Kranj" - ], - "social": [ - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", - "link": "tel:+38651303505" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://www.radio-kranj.si/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", - "link": "https://www.youtube.com/channel/UCe_Ze0SEHCSLLNUbWM0aBgA" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", - "link": "https://www.facebook.com/pages/Radio-Kranj/1760816170864847" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", - "link": "https://www.instagram.com/radiokranj/" - } - ], - "enabled": true, - "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiokranj?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61102", - "rpUid": "705104", - "dabUser": "kranj", - "dabPass": "ui8z3Ezzosyxw", - "dabDefaultImg": "http://media.radio.si/logo/dns/kranj/320x240.png", - "small": false - }, - { - "id": "Celje", - "title": "Radio Celje", - "slogan": "Vedno z menoj", - "logo": "https://data.radio.si/api/radiostations/logo/celje.svg", - "liveAudio": "http://live.radio.si/Celje", - "liveVideo": null, - "poster": null, - "lastSongs": "https://data.radio.si/api/lastsongsxml/celje/json", - "epg": "", - "defaultText": "www.radiocelje.si", - "www": "https://www.radiocelje.si/", - "mountPoints": [ - "Celje" - ], - "social": [ - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", - "link": "tel:+386034225100" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://www.radiocelje.si/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", - "link": "https://www.youtube.com/channel/UC99aNwZXokG6nnJLfSn5DSw" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", - "link": "https://www.facebook.com/radiocelje" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", - "link": "https://www.instagram.com/radiocelje/" - } - ], - "enabled": true, - "radioApiIO": "", - "rpUid": "705117", - "dabUser": "celje", - "dabPass": "dunk7815g", - "dabDefaultImg": "http://media.radio.si/logo/dns/celje/RadioCelje_DAB.jpg", - "small": false - }, - { - "id": "Triglav", - "title": "Radio Triglav", - "slogan": "Radio za radovedne", - "logo": "https://data.radio.si/api/radiostations/logo/triglav.svg", - "liveAudio": "http://live.radio.si/Triglav", - "liveVideo": null, - "poster": null, - "lastSongs": "https://data.radio.si/api/lastsongsxml/triglav/json", - "epg": "http://spored.radio.si/api/now/triglav", - "defaultText": "www.radiotriglav.si", - "www": "https://radiotriglav.si/", - "mountPoints": [ - "Triglav" - ], - "social": [ - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", - "link": "tel:+38651654064" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://www.radiotriglav.si/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", - "link": "https://www.facebook.com/RadioTriglav" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", - "link": "https://www.instagram.com/radiotriglav/" - } - ], - "enabled": true, - "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiotriglav?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=38020", - "rpUid": "705106", - "dabUser": "triglav", - "dabPass": "ogFLUKodMUCB5", - "dabDefaultImg": "http://media.radio.si/logo/dns/triglav/320x240.png", - "small": false - }, - { - "id": "Velenje", - "title": "Radio Velenje", - "slogan": "Ker smo radi na kamot", - "logo": "http://datacache.radio.si/api/radiostations/logo/velenje.svg", - "liveAudio": "http://live.radio.si/Velenje", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/velenje/json", - "epg": "", - "defaultText": "www.veseljak.si", - "www": "https://veseljak.si/", - "mountPoints": [ - "Velenje" - ], - "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": "705118", - "dabUser": "velenje", - "dabPass": "e9mopbu11", - "dabDefaultImg": "http://media.radio.si/logo/dns/velenje/RadioVelenje_DAB.jpg", - "small": false - }, - { - "id": "AktualK", - "title": "Radio Aktual Kum", - "slogan": "Narejen za vaša ušesa", - "logo": "http://datacache.radio.si/api/radiostations/logo/aktualk.svg", - "liveAudio": "http://live.radio.si/AktualK", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/aktualk/json", - "epg": "", - "defaultText": "", - "www": null, - "mountPoints": [ - "AktualK" - ], - "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": "https://www.youtube.com/user/raktual" - }, - { - "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/stories/radioaktual/" - } - ], - "enabled": true, - "radioApiIO": "", - "rpUid": "", - "dabUser": null, - "dabPass": null, - "dabDefaultImg": null, - "small": false - }, - { - "id": "AktualRomantika", - "title": "Radio Aktual - Romantika", - "slogan": "Kot nezna dlan, ki boza te", - "logo": "http://datacache.radio.si/api/radiostations/logo/aktualromantika.svg", - "liveAudio": "http://live.radio.si/AktualRomantika", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/aktualromantika/json", - "epg": "", - "defaultText": "", - "www": null, - "mountPoints": [ - "AktualRomantika" - ], - "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": "https://www.youtube.com/user/raktual" - }, - { - "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/stories/radioaktual/" - } - ], - "enabled": true, - "radioApiIO": "", - "rpUid": "705174", - "dabUser": "romantika", - "dabPass": "Z75biJ5t7CpK", - "dabDefaultImg": "http://media.radio.si/logo/dns/aktualromantika/RadioAktualRomnatika_DAB.jpg", - "small": false - }, - { - "id": "Stop", - "title": "Stop", - "slogan": "Revija Stop: Več kot pol stoletja ob vaši strani!", - "logo": "http://datacache.radio.si/api/radiostations/logo/stop.svg", - "liveAudio": "http://live.radio.si/Stop", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/stop/json", - "epg": "", - "defaultText": "www.revijastop.si", - "www": "https://revijastop.si/", - "mountPoints": [ - "Stop" - ], - "social": [ - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://revijastop.si/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://totiradio.si/" - } - ], - "enabled": true, - "radioApiIO": "", - "rpUid": "", - "dabUser": null, - "dabPass": null, - "dabDefaultImg": null, - "small": false - }, - { - "id": "Radio1SLO", - "title": "Radio 1 slovenski hiti", - "slogan": "Sama dobra slovenska glasba", - "logo": "http://datacache.radio.si/api/radiostations/logo/radio1slo.svg", - "liveAudio": "http://live.radio.si/Radio1SLO", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/radio1slo/json", - "epg": "", - "defaultText": "www.radio1.si", - "www": "https://www.radio1.si", - "mountPoints": [ - "Radio1SLO" - ], - "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://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": "", - "rpUid": "705110", - "dabUser": "1slovenske", - "dabPass": "ionb9hkd48", - "dabDefaultImg": "http://media.radio.si/logo/dns/radio1slo/320x240.png", - "small": false - }, - { - "id": "RockCE", - "title": "Rock Celje", - "slogan": "100% Rock", - "logo": "https://data.radio.si/api/radiostations/logo/rockce.svg", - "liveAudio": "http://live.radio.si/RockCE", - "liveVideo": null, - "poster": null, - "lastSongs": "https://data.radio.si/api/lastsongsxml/rockce/json", - "epg": null, - "defaultText": "www.rock-celje.si", - "www": "https://rock-celje.si/", - "mountPoints": [ - "RockCE" - ], - "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.rock-celje.si/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", - "link": "https://www.facebook.com/RockCelje" - }, - { - "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": "", - "dabUser": null, - "dabPass": null, - "dabDefaultImg": null, - "small": false - }, - { - "id": "Radio1Bozicne", - "title": "ROŠKARJEVE BOŽIČNE", - "slogan": "100% Božične", - "logo": "https://data.radio.si/api/radiostations/logo/radio1bozicne.svg", - "liveAudio": "http://live.radio1.si/Radio1Bozicne", - "liveVideo": null, - "poster": null, - "lastSongs": "https://data.radio.si/api/lastsongsxml/radio1bozicne/json", - "epg": null, - "defaultText": "www.radio1.si", - "www": "https://radio1.si/", - "mountPoints": [ - "Radio1Bozicne" - ], - "social": [ - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", - "link": "https://radio1.si/" - }, - { - "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": "", - "dabUser": null, - "dabPass": null, - "dabDefaultImg": null, - "small": false - }, - { - "id": "TotiBozicni", - "title": "TOTI BOŽIČNI RADIO", - "slogan": "Tvoje mesto, tvoj radio", - "logo": "http://datacache.radio.si/api/radiostations/logo/totibozicne.svg", - "liveAudio": "http://live.radio.si/TotiBozicne", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/totibozicni/json", - "epg": null, - "defaultText": "www.totiradio.si", - "www": "https://totiradio.si/", - "mountPoints": [ - "TotiBozicni" - ], - "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/" - }, - { - "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", - "link": "https://www.youtube.com/user/raktual" - }, - { - "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/stories/radioaktual/" - } - ], - "enabled": true, - "radioApiIO": "", - "rpUid": "", - "dabUser": null, - "dabPass": null, - "dabDefaultImg": null, - "small": false - }, - { - "id": "Hit", - "title": "Radio HIT", - "slogan": "Samo nostalgija", - "logo": "http://datacache.radio.si/api/radiostations/logo/hit.svg", - "liveAudio": "http://live.radio.si/Hit", - "liveVideo": null, - "poster": null, - "lastSongs": "http://data.radio.si/api/lastsongsxml/hit/json", - "epg": "http://spored.radio.si/api/now/hit", - "defaultText": "www.radiohit.si", - "www": "https://radiohit.si/", - "mountPoints": [ - "Hit" - ], - "social": [], - "enabled": true, - "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiohit?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61120", - "rpUid": "705141", - "dabUser": null, - "dabPass": null, - "dabDefaultImg": "http://media.radio.si/logo/dns/hit/320x240.png", - "small": false - } -] \ No newline at end of file diff --git a/android/app/src/main/assets/styles.css b/android/app/src/main/assets/styles.css deleted file mode 100644 index 608aa2b..0000000 --- a/android/app/src/main/assets/styles.css +++ /dev/null @@ -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; -} diff --git a/android/app/src/main/jniLibs/arm64-v8a/libradio_tauri_lib.so b/android/app/src/main/jniLibs/arm64-v8a/libradio_tauri_lib.so deleted file mode 100644 index c5f1aea..0000000 Binary files a/android/app/src/main/jniLibs/arm64-v8a/libradio_tauri_lib.so and /dev/null differ diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libradio_tauri_lib.so b/android/app/src/main/jniLibs/armeabi-v7a/libradio_tauri_lib.so deleted file mode 100644 index 3e48392..0000000 Binary files a/android/app/src/main/jniLibs/armeabi-v7a/libradio_tauri_lib.so and /dev/null differ diff --git a/package-lock.json b/package-lock.json index c53984f..76920b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "devDependencies": { "@tauri-apps/cli": "^2", + "cross-env": "^7.0.3", "npx": "^3.0.0", "rcedit": "^1.1.2" } @@ -255,6 +256,40 @@ "dev": true, "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -303,6 +338,13 @@ "dev": true, "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1661,6 +1703,16 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-1.1.2.tgz", @@ -1682,6 +1734,45 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 87a906b..cdc5c2b 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,20 @@ { "name": "radio-tauri", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "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", - "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" }, "devDependencies": { "@tauri-apps/cli": "^2", + "cross-env": "^7.0.3", "npx": "^3.0.0", "rcedit": "^1.1.2" } diff --git a/receiver/assets/logo.svg b/receiver/assets/logo.svg deleted file mode 100644 index d093b4e..0000000 --- a/receiver/assets/logo.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - Radio - - diff --git a/receiver/index.html b/receiver/index.html deleted file mode 100644 index 4067bad..0000000 --- a/receiver/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Radio Player - - - - - - - -
    -

    Radio Player

    -

    Ready

    - -
    - Radio Player -
    - -

    Radio – Live Stream

    -
    - - - - diff --git a/receiver/receiver.js b/receiver/receiver.js deleted file mode 100644 index 4e82e93..0000000 --- a/receiver/receiver.js +++ /dev/null @@ -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' }); - }); -})(); diff --git a/receiver/styles.css b/receiver/styles.css deleted file mode 100644 index baefcf4..0000000 --- a/receiver/styles.css +++ /dev/null @@ -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; } -} diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 deleted file mode 100644 index 0d32bef..0000000 --- a/scripts/build-android.ps1 +++ /dev/null @@ -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 diff --git a/scripts/build-android.sh b/scripts/build-android.sh deleted file mode 100644 index 769c9e9..0000000 --- a/scripts/build-android.sh +++ /dev/null @@ -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)." diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7197aca..229c74a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3282,7 +3282,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radio-tauri" -version = "0.1.0" +version = "0.1.1" dependencies = [ "base64 0.22.1", "cpal", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f0a2fd9..18b1da4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "radio-tauri" -version = "0.1.0" +version = "0.1.1" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84afd35..4e29973 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,16 @@ 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::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 serde_json::json; @@ -21,6 +31,21 @@ struct AppState { known_devices: Mutex>, } +struct CastProxy { + child: Child, +} + +struct CastProxyState { + inner: Mutex>, +} + +#[derive(serde::Serialize)] +struct CastProxyStartResult { + url: String, + // "tap" | "proxy" + mode: String, +} + // Native (non-WebView) audio player state. // Step 1: state machine + command interface only (no decoding/output yet). 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 { + // 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) { + 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 { + // 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 { + 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 { + // 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] async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result { Ok(player.shared.snapshot()) @@ -177,8 +417,18 @@ async fn cast_play( async fn cast_stop( _app: AppHandle, sidecar_state: State<'_, SidecarState>, + proxy_state: State<'_, CastProxyState>, + player: State<'_, PlayerRuntime>, _device_name: 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(); if let Some(ref mut child) = *lock { let stop_cmd = json!({ "command": "stop", "args": {} }); @@ -282,6 +532,12 @@ pub fn run() { if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { let player = window.app_handle().state::(); 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::(); + let mut lock = proxy_state.inner.lock().unwrap(); + stop_cast_proxy_locked(&mut lock); } }) .setup(|app| { @@ -291,6 +547,9 @@ pub fn run() { app.manage(SidecarState { child: Mutex::new(None), }); + app.manage(CastProxyState { + inner: Mutex::new(None), + }); // Player scaffolding: leak shared state to get a 'static reference for the // long-running thread without complex lifetime plumbing. @@ -342,6 +601,8 @@ pub fn run() { cast_play, cast_stop, cast_set_volume, + cast_proxy_start, + cast_proxy_stop, // allow frontend to request arbitrary URLs via backend (bypass CORS) fetch_url, // fetch remote images via backend (data: URL), helps with mixed-content diff --git a/src-tauri/src/player.rs b/src-tauri/src/player.rs index 3b5a89f..b85e2f6 100644 --- a/src-tauri/src/player.rs +++ b/src-tauri/src/player.rs @@ -11,6 +11,21 @@ use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 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)] #[serde(rename_all = "lowercase")] pub enum PlayerStatus { @@ -53,8 +68,16 @@ impl PlayerShared { #[derive(Debug)] pub enum PlayerCommand { 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, SetVolume { volume: f32 }, + CastTapStart { + port: u16, + reply: mpsc::Sender>, + }, + CastTapStop, Shutdown, } @@ -103,7 +126,7 @@ fn set_error(shared: &'static PlayerShared, message: String) { s.error = Some(message); } -fn ffmpeg_command() -> OsString { +pub(crate) fn ffmpeg_command() -> OsString { // Step 2: external ffmpeg binary. // Lookup order: // 1) RADIOPLAYER_FFMPEG (absolute or relative) @@ -139,19 +162,9 @@ fn ffmpeg_command() -> OsString { OsString::from(local_name) } -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}"))?; - - // Ensure ffmpeg can be executed. +pub fn preflight_ffmpeg_only() -> Result<(), String> { let ffmpeg = ffmpeg_command(); - let status = Command::new(&ffmpeg) + let status = command_hidden(&ffmpeg) .arg("-version") .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -165,38 +178,82 @@ pub fn preflight_check() -> Result<(), String> { if !status.success() { 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(()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PipelineMode { + WithOutput, + Headless, +} + +struct CastTapProc { + child: std::process::Child, + writer_join: Option>, +} + struct Pipeline { stop_flag: Arc, volume_bits: Arc, - _stream: cpal::Stream, + _stream: Option, decoder_join: Option>, + cast_tx: Arc>>>>, + cast_proc: Option, + sample_rate: u32, + channels: u16, } impl Pipeline { - fn start(shared: &'static PlayerShared, url: String) -> Result { - let host = cpal::default_host(); - let device = host - .default_output_device() - .ok_or_else(|| "No default audio output device".to_string())?; - - let default_cfg = device - .default_output_config() - .map_err(|e| format!("Failed to get output config: {e}"))?; - let sample_format = default_cfg.sample_format(); - let cfg = default_cfg.config(); - let sample_rate = cfg.sample_rate.0; - let channels = cfg.channels as u16; + fn start(shared: &'static PlayerShared, url: String, mode: PipelineMode) -> Result { + let (device, sample_format, cfg, sample_rate, channels) = match mode { + PipelineMode::WithOutput => { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "No default audio output device".to_string())?; + let default_cfg = device + .default_output_config() + .map_err(|e| format!("Failed to get output config: {e}"))?; + let sample_format = default_cfg.sample_format(); + let cfg = default_cfg.config(); + let sample_rate = cfg.sample_rate.0; + 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) - let capacity_samples = (sample_rate as usize) - .saturating_mul(cfg.channels as usize) - .saturating_mul(5); - let rb = HeapRb::::new(capacity_samples); - let (mut prod, mut cons) = rb.split(); + 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) + .saturating_mul(cfg.channels as usize) + .saturating_mul(5); + let rb = HeapRb::::new(capacity_samples); + let (prod, cons) = rb.split(); + (Some(prod), Some(cons)) + } else { + (None, None) + }; let stop_flag = Arc::new(AtomicBool::new(false)); let volume_bits = Arc::new(AtomicU32::new({ @@ -204,15 +261,18 @@ impl Pipeline { volume_to_bits(s.volume) })); + let cast_tx: Arc>>>> = Arc::new(Mutex::new(None)); + // Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer. let stop_for_decoder = Arc::clone(&stop_flag); let shared_for_decoder = shared; let decoder_url = url.clone(); + let cast_tx_for_decoder = Arc::clone(&cast_tx); let decoder_join = std::thread::spawn(move || { let mut backoff_ms: u64 = 250; let mut pushed_since_start: usize = 0; let playing_threshold_samples = (sample_rate as usize) - .saturating_mul(cfg.channels as usize) + .saturating_mul(channels as usize) .saturating_div(4); // ~250ms 'outer: loop { @@ -224,7 +284,7 @@ impl Pipeline { let ffmpeg = ffmpeg_command(); let ffmpeg_disp = ffmpeg.to_string_lossy(); - let mut child = match Command::new(&ffmpeg) + let mut child = match command_hidden(&ffmpeg) .arg("-nostdin") .arg("-hide_banner") .arg("-loglevel") @@ -303,13 +363,21 @@ impl Pipeline { 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 let mut i = 0usize; if let Some(b0) = leftover.take() { if n >= 1 { let b1 = buf[0]; let sample = i16::from_le_bytes([b0, b1]); - let _ = prod.push(sample); + if let Some(prod) = prod_opt.as_mut() { + let _ = prod.push(sample); + } pushed_since_start += 1; i = 1; } else { @@ -319,9 +387,10 @@ impl Pipeline { while i + 1 < n { let sample = i16::from_le_bytes([buf[i], buf[i + 1]]); - if prod.push(sample).is_ok() { - pushed_since_start += 1; + if let Some(prod) = prod_opt.as_mut() { + let _ = prod.push(sample); } + pushed_since_start += 1; i += 2; } @@ -337,146 +406,256 @@ impl Pipeline { } }); - // Audio callback: drain ring buffer and write to output. - let shared_for_cb = shared; - let stop_for_cb = Arc::clone(&stop_flag); - let volume_for_cb = Arc::clone(&volume_bits); + 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"); - let mut last_was_underrun = false; + // Audio callback: drain ring buffer and write to output. + let shared_for_cb = shared; + let stop_for_cb = Arc::clone(&stop_flag); + let volume_for_cb = Arc::clone(&volume_bits); - let err_fn = move |err| { - let msg = format!("Audio output error: {err}"); - set_error(shared_for_cb, msg); + let mut last_was_underrun = false; + + let err_fn = move |err| { + let msg = format!("Audio output error: {err}"); + set_error(shared_for_cb, msg); + }; + + let built = match sample_format { + cpal::SampleFormat::F32 => device.build_output_stream( + &cfg, + move |data: &mut [f32], _| { + if stop_for_cb.load(Ordering::Relaxed) { + for s in data.iter_mut() { + *s = 0.0; + } + return; + } + + let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed)); + let mut underrun = false; + for s in data.iter_mut() { + if let Some(v) = cons.pop() { + *s = (v as f32 / 32768.0) * vol; + } else { + *s = 0.0; + underrun = true; + } + } + if underrun != last_was_underrun { + last_was_underrun = underrun; + set_status( + shared_for_cb, + if underrun { + PlayerStatus::Buffering + } else { + PlayerStatus::Playing + }, + ); + } + }, + err_fn, + None, + ), + cpal::SampleFormat::I16 => device.build_output_stream( + &cfg, + move |data: &mut [i16], _| { + if stop_for_cb.load(Ordering::Relaxed) { + for s in data.iter_mut() { + *s = 0; + } + return; + } + + let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed)); + let mut underrun = false; + for s in data.iter_mut() { + if let Some(v) = cons.pop() { + let scaled = + (v as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32); + *s = scaled as i16; + } else { + *s = 0; + underrun = true; + } + } + if underrun != last_was_underrun { + last_was_underrun = underrun; + set_status( + shared_for_cb, + if underrun { + PlayerStatus::Buffering + } else { + PlayerStatus::Playing + }, + ); + } + }, + err_fn, + None, + ), + cpal::SampleFormat::U16 => device.build_output_stream( + &cfg, + move |data: &mut [u16], _| { + if stop_for_cb.load(Ordering::Relaxed) { + for s in data.iter_mut() { + *s = 0; + } + return; + } + + let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed)); + let mut underrun = false; + for s in data.iter_mut() { + if let Some(v) = cons.pop() { + // Convert signed i16 to unsigned with bias. + let f = (v as f32 / 32768.0) * vol; + let scaled = (f * 32767.0 + 32768.0).clamp(0.0, 65535.0); + *s = scaled as u16; + } else { + *s = 0; + underrun = true; + } + } + if underrun != last_was_underrun { + last_was_underrun = underrun; + set_status( + shared_for_cb, + if underrun { + PlayerStatus::Buffering + } else { + PlayerStatus::Playing + }, + ); + } + }, + err_fn, + None, + ), + _ => return Err("Unsupported output sample format".to_string()), + } + .map_err(|e| format!("Failed to create output stream: {e}"))?; + + built + .play() + .map_err(|e| format!("Failed to start output stream: {e}"))?; + Some(built) + } else { + None }; - let stream = match sample_format { - cpal::SampleFormat::F32 => device.build_output_stream( - &cfg, - move |data: &mut [f32], _| { - if stop_for_cb.load(Ordering::Relaxed) { - for s in data.iter_mut() { - *s = 0.0; - } - return; - } - - let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed)); - let mut underrun = false; - for s in data.iter_mut() { - if let Some(v) = cons.pop() { - *s = (v as f32 / 32768.0) * vol; - } else { - *s = 0.0; - underrun = true; - } - } - if underrun != last_was_underrun { - last_was_underrun = underrun; - set_status( - shared_for_cb, - if underrun { - PlayerStatus::Buffering - } else { - PlayerStatus::Playing - }, - ); - } - }, - err_fn, - None, - ), - cpal::SampleFormat::I16 => device.build_output_stream( - &cfg, - move |data: &mut [i16], _| { - if stop_for_cb.load(Ordering::Relaxed) { - for s in data.iter_mut() { - *s = 0; - } - return; - } - - let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed)); - let mut underrun = false; - for s in data.iter_mut() { - if let Some(v) = cons.pop() { - let scaled = (v as f32 * vol).clamp(i16::MIN as f32, i16::MAX as f32); - *s = scaled as i16; - } else { - *s = 0; - underrun = true; - } - } - if underrun != last_was_underrun { - last_was_underrun = underrun; - set_status( - shared_for_cb, - if underrun { - PlayerStatus::Buffering - } else { - PlayerStatus::Playing - }, - ); - } - }, - err_fn, - None, - ), - cpal::SampleFormat::U16 => device.build_output_stream( - &cfg, - move |data: &mut [u16], _| { - if stop_for_cb.load(Ordering::Relaxed) { - for s in data.iter_mut() { - *s = 0; - } - return; - } - - let vol = volume_from_bits(volume_for_cb.load(Ordering::Relaxed)); - let mut underrun = false; - for s in data.iter_mut() { - if let Some(v) = cons.pop() { - // Convert signed i16 to unsigned with bias. - let f = (v as f32 / 32768.0) * vol; - let scaled = (f * 32767.0 + 32768.0).clamp(0.0, 65535.0); - *s = scaled as u16; - } else { - *s = 0; - underrun = true; - } - } - if underrun != last_was_underrun { - last_was_underrun = underrun; - set_status( - shared_for_cb, - if underrun { - PlayerStatus::Buffering - } else { - PlayerStatus::Playing - }, - ); - } - }, - err_fn, - None, - ), - _ => return Err("Unsupported output sample format".to_string()), - } - .map_err(|e| format!("Failed to create output stream: {e}"))?; - - stream - .play() - .map_err(|e| format!("Failed to start output stream: {e}"))?; - Ok(Self { stop_flag, volume_bits, _stream: stream, 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 { + 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::>(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) { self.stop_flag.store(true, Ordering::SeqCst); + self.stop_cast_tap(); // dropping stream stops audio if let Some(j) = self.decoder_join.take() { let _ = j.join(); @@ -492,6 +671,7 @@ impl Pipeline { fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver) { // Step 2: FFmpeg decode + CPAL playback. let mut pipeline: Option = None; + let mut pipeline_cast_owned = false; while let Ok(cmd) = rx.recv() { match cmd { PlayerCommand::Play { url } => { @@ -499,6 +679,8 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver { // Apply current volume to pipeline atomics. let vol = { shared.state.lock().unwrap().volume }; @@ -519,6 +701,32 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver { + 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 => { if let Some(p) = pipeline.take() { p.stop(shared); @@ -527,6 +735,7 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver { let v = clamp01(volume); @@ -538,6 +747,26 @@ fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver { + 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, } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 702e58c..9a0c230 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "RadioPlayer", - "version": "0.1.0", + "version": "0.1.1", "identifier": "si.klevze.radioPlayer", "build": { "frontendDist": "../src" diff --git a/src/main.js b/src/main.js index 3da50e9..ba8146e 100644 --- a/src/main.js +++ b/src/main.js @@ -11,6 +11,7 @@ let currentIndex = 0; let isPlaying = false; let currentMode = 'local'; // 'local' | 'cast' let currentCastDevice = null; +let currentCastTransport = null; // 'tap' | 'proxy' | 'direct' | null // Local playback is handled natively by the Tauri backend (player_* commands). // The WebView is a control surface only. @@ -158,12 +159,63 @@ const usIndex = document.getElementById('us_index'); // Init async function init() { 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(); await loadStations(); + try { console.log('stations loaded:', Array.isArray(stations) ? stations.length : typeof stations); } catch (_) {} setupEventListeners(); ensureArtworkPointerFallback(); updateUI(); 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) { console.error('Error during init', 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) { // Cast logic 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; // Sync volume const vol = volumeSlider.value / 100; @@ -1169,8 +1253,10 @@ async function play() { updateUI(); } catch (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 + currentCastTransport = null; updateUI(); } } @@ -1186,6 +1272,7 @@ async function stop() { } } else if (currentMode === 'cast' && currentCastDevice) { try { + await invoke('cast_proxy_stop').catch(() => {}); await invoke('cast_stop', { deviceName: currentCastDevice }); } catch (e) { console.error(e); @@ -1193,6 +1280,9 @@ async function stop() { } isPlaying = false; + if (currentMode !== 'cast') { + currentCastTransport = null; + } updateUI(); } @@ -1220,14 +1310,24 @@ function updateUI() { 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'; + if (currentMode === 'cast') { + const t = currentCastTransport ? ` (${currentCastTransport})` : ''; + stationSubtitleEl.textContent = `Casting${t} to ${currentCastDevice}`; + } else { + stationSubtitleEl.textContent = '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'; + if (currentMode === 'cast') { + const t = currentCastTransport ? ` (${currentCastTransport})` : ''; + stationSubtitleEl.textContent = `Connected${t} to ${currentCastDevice}`; + } else { + stationSubtitleEl.textContent = 'Live Stream'; + } } updateEngineBadge(); @@ -1296,14 +1396,20 @@ async function selectCastDevice(deviceName) { await stop(); } + // Best-effort cleanup: stop any lingering cast transport when changing device/mode. + await invoke('cast_proxy_stop').catch(() => {}); + if (deviceName) { currentMode = 'cast'; currentCastDevice = deviceName; castBtn.style.color = 'var(--success)'; + // Transport mode gets set on play. + currentCastTransport = currentCastTransport || null; } else { currentMode = 'local'; currentCastDevice = null; castBtn.style.color = 'var(--text-main)'; + currentCastTransport = null; } updateUI(); @@ -1313,22 +1419,51 @@ async function selectCastDevice(deviceName) { // 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); // 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`. if ('serviceWorker' in navigator) { if (runningInTauri) { - // Best-effort cleanup so the desktop app always reflects local file changes. - navigator.serviceWorker.getRegistrations() - .then((regs) => Promise.all(regs.map((r) => r.unregister()))) - .catch(() => {}); + // Best-effort cleanup so the desktop app doesn't get stuck on an old cached UI. + // If we clear anything, do a one-time reload to ensure the new bundled assets are used. + (async () => { + let changed = false; - if ('caches' in window) { - caches.keys() - .then((keys) => Promise.all(keys.map((k) => caches.delete(k)))) - .catch(() => {}); - } + 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) { + try { + const keys = await caches.keys(); + 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 { // Register Service Worker for PWA installation (non-disruptive) window.addEventListener('load', () => { @@ -1400,7 +1535,9 @@ async function openStationsOverlay() { li.onclick = async () => { currentMode = 'local'; currentCastDevice = null; + currentCastTransport = null; castBtn.style.color = 'var(--text-main)'; + try { await invoke('cast_proxy_stop'); } catch (_) {} await setStationByIndex(idx); closeCastOverlay(); try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } diff --git a/src/sw.js b/src/sw.js index b8362ea..15cae38 100644 --- a/src/sw.js +++ b/src/sw.js @@ -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 = [ '.', 'index.html', @@ -7,14 +13,25 @@ const CORE_ASSETS = [ 'stations.json', 'assets/favicon_io/android-chrome-192x192.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) => { // Activate updated SW as soon as it's installed. self.skipWaiting(); 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 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( caches.match(event.request).then((cached) => { if (cached) return cached; diff --git a/tools/sync-version.js b/tools/sync-version.js new file mode 100644 index 0000000..402106c --- /dev/null +++ b/tools/sync-version.js @@ -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); +} diff --git a/tools/write-build-flag.js b/tools/write-build-flag.js new file mode 100644 index 0000000..4000efd --- /dev/null +++ b/tools/write-build-flag.js @@ -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); +} diff --git a/webapp/README.md b/webapp/README.md deleted file mode 100644 index a025f30..0000000 --- a/webapp/README.md +++ /dev/null @@ -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`. diff --git a/webapp/assets/favicon_io/site.webmanifest b/webapp/assets/favicon_io/site.webmanifest deleted file mode 100644 index 1dd9112..0000000 --- a/webapp/assets/favicon_io/site.webmanifest +++ /dev/null @@ -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"} diff --git a/webapp/index.html b/webapp/index.html deleted file mode 100644 index 065e57d..0000000 --- a/webapp/index.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - - - - RadioPlayer - - - - - - - - - -
    -
    -
    - -
    -
    -
    - - - - -
    - -
    -
    - -
    -
    - - -
    -
    -
    - -
    -
    -
    - - - - - - - - - - - - - - - - 1 -
    -
    -
    - -
    -

    - -

    - -
    - - -
    -
    -
    -
    -
    -
    - -
    - - - - - -
    - -
    - -
    - -
    - 50% -
    - - - - - - - -
    -
    - - - diff --git a/webapp/manifest.json b/webapp/manifest.json deleted file mode 100644 index a27e292..0000000 --- a/webapp/manifest.json +++ /dev/null @@ -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" - } - ] -} diff --git a/webapp/package-lock.json b/webapp/package-lock.json deleted file mode 100644 index f43a09e..0000000 --- a/webapp/package-lock.json +++ /dev/null @@ -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 - } - } - } - } -} diff --git a/webapp/package.json b/webapp/package.json deleted file mode 100644 index 148ec13..0000000 --- a/webapp/package.json +++ /dev/null @@ -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" - } -} diff --git a/webapp/src/main.js b/webapp/src/main.js deleted file mode 100644 index 08d2863..0000000 --- a/webapp/src/main.js +++ /dev/null @@ -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 = ` -
    -

    RadioCast (Web)

    -

    Running as a plain web application (no Tauri).

    -
    Status: Idle
    -
    - `; - - console.log('RadioCast webapp started (web mode)'); -}); diff --git a/webapp/stations.json b/webapp/stations.json deleted file mode 100644 index 05b58d3..0000000 --- a/webapp/stations.json +++ /dev/null @@ -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, diff --git a/webapp/styles.css b/webapp/styles.css deleted file mode 100644 index 1748b27..0000000 --- a/webapp/styles.css +++ /dev/null @@ -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; -} diff --git a/webapp/sw.js b/webapp/sw.js deleted file mode 100644 index fa201a5..0000000 --- a/webapp/sw.js +++ /dev/null @@ -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' }); - }); - }) - ); -}); diff --git a/webapp/vite.config.js b/webapp/vite.config.js deleted file mode 100644 index 4d94e34..0000000 --- a/webapp/vite.config.js +++ /dev/null @@ -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, '..')] - } - } -});