Compare commits
8 Commits
f9b9ce0994
...
c954bf25d4
| Author | SHA1 | Date | |
|---|---|---|---|
| c954bf25d4 | |||
| 98a6ba88fc | |||
| 916cc7764a | |||
| 694f335408 | |||
| abb7cafaed | |||
| d45fe0fbde | |||
| c4020615d2 | |||
| 34c3f0dc89 |
170
.ai/tauris-agent.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# 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
|
||||||
|
- Chromecast / casting architectures
|
||||||
|
- Incremental refactors of production apps
|
||||||
|
|
||||||
|
You are working on an existing project named **Taurus RadioPlayer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROJECT CONTEXT (IMPORTANT)
|
||||||
|
|
||||||
|
This is a **Tauri desktop application**, NOT Electron.
|
||||||
|
|
||||||
|
### Current architecture
|
||||||
|
|
||||||
|
- Frontend: Vanilla HTML / CSS / JS served in WebView
|
||||||
|
- Backend: Rust (Tauri commands)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Browser/HTML5 audio is insufficient for:
|
||||||
|
|
||||||
|
- stable radio streaming
|
||||||
|
- buffering control
|
||||||
|
- reconnection
|
||||||
|
- unified local + cast playback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PRIMARY GOAL
|
||||||
|
|
||||||
|
Upgrade the application by:
|
||||||
|
|
||||||
|
1. **Removing HTML5 Audio completely**
|
||||||
|
2. **Implementing a native audio streaming engine**
|
||||||
|
3. **Keeping the existing HTML/CSS UI unchanged**
|
||||||
|
4. **Preserving the current station model and UX**
|
||||||
|
5. **Maintaining cross-platform compatibility**
|
||||||
|
6. **Avoiding unnecessary rewrites**
|
||||||
|
|
||||||
|
This is an **incremental upgrade**, not a rewrite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TARGET ARCHITECTURE
|
||||||
|
|
||||||
|
- UI remains WebView-based (HTML/CSS/JS)
|
||||||
|
- JS communicates only via Tauri `invoke()`
|
||||||
|
- Audio decoding and playback are handled natively
|
||||||
|
- 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.)
|
||||||
|
- 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
|
||||||
|
- GStreamer (if justified)
|
||||||
|
- Implement commands such as:
|
||||||
|
- `player_play(url)`
|
||||||
|
- `player_stop()`
|
||||||
|
- `player_set_volume(volume)`
|
||||||
|
- `player_get_state()`
|
||||||
|
- Handle:
|
||||||
|
- buffering
|
||||||
|
- reconnect on stream drop
|
||||||
|
- clean shutdown
|
||||||
|
- thread safety
|
||||||
|
|
||||||
|
### 3. Casting rules
|
||||||
|
|
||||||
|
- Do not break existing Chromecast support
|
||||||
|
- 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
4. Preserve compatibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WHAT YOU SHOULD PRODUCE
|
||||||
|
|
||||||
|
You may generate:
|
||||||
|
|
||||||
|
- Rust code (Tauri commands, audio engine)
|
||||||
|
- JS changes (invoke-based playback)
|
||||||
|
- Architecture explanations
|
||||||
|
- Migration steps
|
||||||
|
- TODO lists
|
||||||
|
- Warnings about pitfalls
|
||||||
|
|
||||||
|
You MUST NOT:
|
||||||
|
|
||||||
|
- Suggest Electron or Flutter
|
||||||
|
- Suggest full rewrites
|
||||||
|
- Ignore existing sidecar or station model
|
||||||
|
- Break the current UX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ENGINEERING PHILOSOPHY
|
||||||
|
|
||||||
|
This app should evolve into:
|
||||||
|
|
||||||
|
> “A native audio engine with a web UI shell”
|
||||||
|
|
||||||
|
The WebView is a **control surface**, not a media engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## COMMUNICATION STYLE
|
||||||
|
|
||||||
|
- Be precise
|
||||||
|
- Be pragmatic
|
||||||
|
- Be production-oriented
|
||||||
|
- Prefer correctness over novelty
|
||||||
|
- Assume this is a real app with users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
4. Planning the replacement step-by-step
|
||||||
|
|
||||||
|
Do NOT write all code at once.
|
||||||
34
.gitignore
vendored
@@ -1,3 +1,37 @@
|
|||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Tauri / Rust
|
||||||
|
/target/
|
||||||
|
/src-tauri/binaries/
|
||||||
|
/src-tauri/target/
|
||||||
|
|
||||||
|
# Local build artifacts
|
||||||
|
/dist/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# FFmpeg / downloaded binaries
|
||||||
|
/ffmpeg/bin/
|
||||||
|
|
||||||
|
# Editor / OS files
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs and temp
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Generated by tools
|
||||||
|
/tools/*.cache
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
41
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):
|
To start the application in development mode (with hot-reloading for frontend changes):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run tauri dev
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want FFmpeg to be bundled into `src-tauri/resources/` for local/native playback during dev, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:native
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will:
|
This command will:
|
||||||
@@ -50,7 +56,7 @@ To create an optimized, standalone executable for your operating system:
|
|||||||
|
|
||||||
1. **Run the build command**:
|
1. **Run the build command**:
|
||||||
```bash
|
```bash
|
||||||
npm run tauri build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Locate the artifacts**:
|
2. **Locate the artifacts**:
|
||||||
@@ -67,7 +73,9 @@ To create an optimized, standalone executable for your operating system:
|
|||||||
* `styles.css`: Application styling.
|
* `styles.css`: Application styling.
|
||||||
* `stations.json`: Configuration file for available radio streams.
|
* `stations.json`: Configuration file for available radio streams.
|
||||||
* **`src-tauri/`**: Rust backend code.
|
* **`src-tauri/`**: Rust backend code.
|
||||||
* `src/main.rs`: The entry point for the Rust process. Handles Google Cast discovery and playback logic.
|
* `src/lib.rs`: Tauri command layer (native player commands, Cast commands, utility HTTP helpers).
|
||||||
|
* `src/player.rs`: Native audio engine (FFmpeg decode → PCM ring buffer → CPAL output).
|
||||||
|
* `src/main.rs`: Rust entry point (wires the Tauri app; most command logic lives in `lib.rs`).
|
||||||
* `tauri.conf.json`: Configuration for the Tauri app (window size, permissions, package info).
|
* `tauri.conf.json`: Configuration for the Tauri app (window size, permissions, package info).
|
||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
@@ -103,6 +111,33 @@ To change the default window size, edit `src-tauri/tauri.conf.json`:
|
|||||||
* **WebView2 Error (Windows)**: If the app doesn't start on Windows, ensure the [Microsoft Edge WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) is installed.
|
* **WebView2 Error (Windows)**: If the app doesn't start on Windows, ensure the [Microsoft Edge WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) is installed.
|
||||||
* **Build Failures**: Try running `cargo update` inside the `src-tauri` folder to update Rust dependencies.
|
* **Build Failures**: Try running `cargo update` inside the `src-tauri` folder to update Rust dependencies.
|
||||||
|
|
||||||
|
## FFmpeg (Optional) for Native Playback
|
||||||
|
|
||||||
|
Local/native playback uses an external **FFmpeg** binary to decode radio streams.
|
||||||
|
|
||||||
|
### How the app finds FFmpeg
|
||||||
|
|
||||||
|
At runtime it searches in this order:
|
||||||
|
|
||||||
|
1. `RADIOPLAYER_FFMPEG` environment variable (absolute or relative path)
|
||||||
|
2. Next to the application executable (Windows: `ffmpeg.exe`, macOS/Linux: `ffmpeg`)
|
||||||
|
3. Common bundle resource folders relative to the executable:
|
||||||
|
- `resources/ffmpeg(.exe)`
|
||||||
|
- `Resources/ffmpeg(.exe)`
|
||||||
|
- `../resources/ffmpeg(.exe)`
|
||||||
|
- `../Resources/ffmpeg(.exe)`
|
||||||
|
4. Your system `PATH`
|
||||||
|
|
||||||
|
### Optional: download FFmpeg automatically (Windows)
|
||||||
|
|
||||||
|
This is **opt-in** (it is not run automatically during build/run). It downloads a prebuilt FFmpeg zip and extracts `ffmpeg.exe` into `tools/ffmpeg/bin/ffmpeg.exe`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ffmpeg:download
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `npm run dev:native` (or `npm run build`) to copy FFmpeg into `src-tauri/resources/` for bundling.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Add License Information Here]
|
[Add License Information Here]
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ This document describes the desktop (Tauri) application architecture, build pipe
|
|||||||
|
|
||||||
- **Frontend (WebView)**: Vanilla HTML/CSS/JS in [src/index.html](src/index.html), [src/main.js](src/main.js), [src/styles.css](src/styles.css)
|
- **Frontend (WebView)**: Vanilla HTML/CSS/JS in [src/index.html](src/index.html), [src/main.js](src/main.js), [src/styles.css](src/styles.css)
|
||||||
- **Tauri host (Rust)**: Command layer + device discovery in [src-tauri/src/lib.rs](src-tauri/src/lib.rs)
|
- **Tauri host (Rust)**: Command layer + device discovery in [src-tauri/src/lib.rs](src-tauri/src/lib.rs)
|
||||||
|
- **Native audio engine (Rust)**: FFmpeg decode + CPAL output in [src-tauri/src/player.rs](src-tauri/src/player.rs)
|
||||||
- **Cast sidecar (Node executable)**: Google Cast control via `castv2-client` in [sidecar/index.js](sidecar/index.js)
|
- **Cast sidecar (Node executable)**: Google Cast control via `castv2-client` in [sidecar/index.js](sidecar/index.js)
|
||||||
- **Packaging utilities**:
|
- **Packaging utilities**:
|
||||||
- Sidecar binary copy/rename step: [tools/copy-binaries.js](tools/copy-binaries.js)
|
- Sidecar binary copy/rename step: [tools/copy-binaries.js](tools/copy-binaries.js)
|
||||||
- Windows EXE icon patch: [tools/post-build-rcedit.js](tools/post-build-rcedit.js)
|
- Windows EXE icon patch: [tools/post-build-rcedit.js](tools/post-build-rcedit.js)
|
||||||
|
- Optional FFmpeg bundling helper: [tools/copy-ffmpeg.js](tools/copy-ffmpeg.js) (see [tools/ffmpeg/README.md](tools/ffmpeg/README.md))
|
||||||
|
|
||||||
Data flow:
|
Data flow:
|
||||||
|
|
||||||
1. UI actions call JS functions in `main.js`.
|
1. UI actions call JS functions in `main.js`.
|
||||||
2. When in **Cast mode**, JS calls Tauri commands via `window.__TAURI__.core.invoke()`.
|
2. JS calls Tauri commands via `window.__TAURI__.core.invoke()` (for both local playback and casting).
|
||||||
3. The Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`.
|
3. In **Local mode**, Rust spawns FFmpeg and plays decoded PCM via CPAL.
|
||||||
4. On `cast_play/stop/volume`, Rust spawns (or reuses) a **sidecar process**, then sends newline-delimited JSON commands to the sidecar stdin.
|
4. In **Cast mode**, the Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`.
|
||||||
|
5. On `cast_play/stop/volume`, Rust spawns (or reuses) a **sidecar process**, then sends newline-delimited JSON commands to the sidecar stdin.
|
||||||
|
|
||||||
## Running and building
|
## Running and building
|
||||||
|
|
||||||
@@ -113,6 +116,29 @@ When a device is resolved:
|
|||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
|
### Native player commands (local playback)
|
||||||
|
|
||||||
|
Local playback is handled by the Rust engine in [src-tauri/src/player.rs](src-tauri/src/player.rs). The UI controls it using these commands:
|
||||||
|
|
||||||
|
#### `player_play(url: String) -> Result<(), String>`
|
||||||
|
|
||||||
|
- Starts native playback of the provided stream URL.
|
||||||
|
- Internally spawns FFmpeg to decode into `s16le` PCM and feeds a ring buffer consumed by a CPAL output stream.
|
||||||
|
- Reports `buffering` → `playing` based on buffer fill/underrun.
|
||||||
|
|
||||||
|
#### `player_stop() -> Result<(), String>`
|
||||||
|
|
||||||
|
- Stops the native pipeline and updates state.
|
||||||
|
|
||||||
|
#### `player_set_volume(volume: f32) -> Result<(), String>`
|
||||||
|
|
||||||
|
- Sets volume in range `[0, 1]`.
|
||||||
|
|
||||||
|
#### `player_get_state() -> Result<PlayerState, String>`
|
||||||
|
|
||||||
|
- Returns `{ status, url, volume, error }`.
|
||||||
|
- Used by the UI to keep status text and play/stop button in sync.
|
||||||
|
|
||||||
#### `list_cast_devices() -> Result<Vec<String>, String>`
|
#### `list_cast_devices() -> Result<Vec<String>, String>`
|
||||||
|
|
||||||
- Returns the sorted list of discovered Cast device names.
|
- Returns the sorted list of discovered Cast device names.
|
||||||
@@ -214,7 +240,8 @@ State is tracked in JS:
|
|||||||
|
|
||||||
#### Local mode
|
#### Local mode
|
||||||
|
|
||||||
- Uses `new Audio()` and sets `audio.src = station.url`.
|
- Uses backend invokes: `player_play`, `player_stop`, `player_set_volume`.
|
||||||
|
- The UI polls `player_get_state` to reflect `buffering/playing/stopped/error`.
|
||||||
|
|
||||||
#### Cast mode
|
#### Cast mode
|
||||||
|
|
||||||
@@ -274,7 +301,7 @@ Note:
|
|||||||
|
|
||||||
- `#play-btn`
|
- `#play-btn`
|
||||||
- Toggles play/stop (`togglePlay()`):
|
- Toggles play/stop (`togglePlay()`):
|
||||||
- Local mode: `audio.play()` / `audio.pause()`.
|
- Local mode: `invoke('player_play')` / `invoke('player_stop')`.
|
||||||
- Cast mode: `invoke('cast_play')` / `invoke('cast_stop')`.
|
- Cast mode: `invoke('cast_play')` / `invoke('cast_stop')`.
|
||||||
- `#prev-btn`
|
- `#prev-btn`
|
||||||
- Previous station (`playPrev()` → `setStationByIndex()`).
|
- Previous station (`playPrev()` → `setStationByIndex()`).
|
||||||
@@ -284,7 +311,7 @@ Note:
|
|||||||
### Volume
|
### Volume
|
||||||
|
|
||||||
- `#volume-slider`
|
- `#volume-slider`
|
||||||
- Local: sets `audio.volume`.
|
- Local: `invoke('player_set_volume')`.
|
||||||
- Cast: `invoke('cast_set_volume')`.
|
- Cast: `invoke('cast_set_volume')`.
|
||||||
- Persists `localStorage.volume`.
|
- Persists `localStorage.volume`.
|
||||||
- `#mute-btn`
|
- `#mute-btn`
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
Before Width: | Height: | Size: 682 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 859 B |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -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"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 995 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
|
|
||||||
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
|
|
||||||
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 289 B |
@@ -1,158 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Radio Player</title>
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
<script src="main.js" defer type="module"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div class="bg-shape shape-1"></div>
|
|
||||||
<div class="bg-shape shape-2"></div>
|
|
||||||
|
|
||||||
<main class="glass-card">
|
|
||||||
<header data-tauri-drag-region>
|
|
||||||
<button id="menu-btn" class="icon-btn" aria-label="Menu">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
||||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="header-info" data-tauri-drag-region>
|
|
||||||
<span class="app-title">Radio1 Player</span>
|
|
||||||
<span class="status-indicator" id="status-indicator">
|
|
||||||
<span class="status-dot"></span> <span id="status-text">Ready</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-buttons">
|
|
||||||
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="close-btn" class="icon-btn close-btn" aria-label="Close">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="artwork-section">
|
|
||||||
<div class="artwork-container">
|
|
||||||
<div class="artwork-placeholder">
|
|
||||||
<!-- Gooey SVG filter for fluid blob blending -->
|
|
||||||
<svg width="0" height="0" style="position:absolute">
|
|
||||||
<defs>
|
|
||||||
<filter id="goo">
|
|
||||||
<!-- increased blur for smoother, more transparent blending -->
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
|
|
||||||
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
|
|
||||||
<feBlend in="SourceGraphic" in2="goo" />
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="logo-blobs" aria-hidden="true">
|
|
||||||
<span class="blob b1"></span>
|
|
||||||
<span class="blob b2"></span>
|
|
||||||
<span class="blob b3"></span>
|
|
||||||
<span class="blob b4"></span>
|
|
||||||
<span class="blob b5"></span>
|
|
||||||
<span class="blob b6"></span>
|
|
||||||
<span class="blob b7"></span>
|
|
||||||
<span class="blob b8"></span>
|
|
||||||
<span class="blob b9"></span>
|
|
||||||
<span class="blob b10"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
|
||||||
<span class="station-logo-text">1</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="track-info">
|
|
||||||
<h2 id="station-name"></h2>
|
|
||||||
<p id="station-subtitle"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Visual Progress Bar (Live) -->
|
|
||||||
<div class="progress-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill"></div>
|
|
||||||
<div class="progress-handle"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="controls-section">
|
|
||||||
<button id="prev-btn" class="control-btn secondary" aria-label="Previous Station">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="play-btn" class="control-btn primary" aria-label="Play">
|
|
||||||
<div class="icon-container">
|
|
||||||
<!-- Play Icon -->
|
|
||||||
<svg id="icon-play" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
<!-- Stop/Pause Icon (Hidden by default) -->
|
|
||||||
<svg id="icon-stop" class="hidden" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 6h12v12H6z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="next-btn" class="control-btn secondary" aria-label="Next Station">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="volume-section">
|
|
||||||
<button id="mute-btn" class="icon-btn small">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
|
||||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="slider-container">
|
|
||||||
<input type="range" id="volume-slider" min="0" max="100" value="50">
|
|
||||||
</div>
|
|
||||||
<span id="volume-value">50%</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Hidden Cast Overlay (Beautified) -->
|
|
||||||
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
|
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
|
|
||||||
<h2 id="deviceTitle">Choose</h2>
|
|
||||||
|
|
||||||
<ul id="device-list" class="device-list">
|
|
||||||
<!-- Render device items here -->
|
|
||||||
<li class="device">
|
|
||||||
<div class="device-main">Scanning...</div>
|
|
||||||
<div class="device-sub">Searching for speakers</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,355 +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;
|
|
||||||
const audio = new Audio();
|
|
||||||
|
|
||||||
// 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 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
const numberMatch = station.name.match(/\d+/);
|
|
||||||
if (numberMatch) {
|
|
||||||
logoTextEl.textContent = numberMatch[0];
|
|
||||||
} else {
|
|
||||||
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
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') {
|
|
||||||
audio.src = station.url;
|
|
||||||
audio.volume = volumeSlider.value / 100;
|
|
||||||
try {
|
|
||||||
await audio.play();
|
|
||||||
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') {
|
|
||||||
audio.pause();
|
|
||||||
audio.src = '';
|
|
||||||
} 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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleVolumeInput() {
|
|
||||||
const val = volumeSlider.value;
|
|
||||||
volumeValue.textContent = `${val}%`;
|
|
||||||
const decimals = val / 100;
|
|
||||||
|
|
||||||
if (currentMode === 'local') {
|
|
||||||
audio.volume = decimals;
|
|
||||||
} else if (currentMode === 'cast' && currentCastDevice) {
|
|
||||||
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cast Logic
|
|
||||||
async function openCastOverlay() {
|
|
||||||
castOverlay.classList.remove('hidden');
|
|
||||||
castOverlay.setAttribute('aria-hidden', 'false');
|
|
||||||
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const devices = await invoke('list_cast_devices');
|
|
||||||
deviceListEl.innerHTML = '';
|
|
||||||
|
|
||||||
// Add "This Computer" option
|
|
||||||
const localLi = document.createElement('li');
|
|
||||||
localLi.className = 'device' + (currentMode === 'local' ? ' selected' : '');
|
|
||||||
localLi.innerHTML = '<div class="device-main">This Computer</div><div class="device-sub">Local Playback</div>';
|
|
||||||
localLi.onclick = () => selectCastDevice(null);
|
|
||||||
deviceListEl.appendChild(localLi);
|
|
||||||
|
|
||||||
if (devices.length > 0) {
|
|
||||||
devices.forEach(d => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'device' + (currentMode === 'cast' && currentCastDevice === d ? ' selected' : '');
|
|
||||||
li.innerHTML = `<div class="device-main">${d}</div><div class="device-sub">Google Cast Speaker</div>`;
|
|
||||||
li.onclick = () => selectCastDevice(d);
|
|
||||||
deviceListEl.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Error</div><div class="device-sub">${e}</div></li>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCastOverlay() {
|
|
||||||
castOverlay.classList.add('hidden');
|
|
||||||
castOverlay.setAttribute('aria-hidden', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectCastDevice(deviceName) {
|
|
||||||
closeCastOverlay();
|
|
||||||
|
|
||||||
// If checking same device, do nothing
|
|
||||||
if (deviceName === currentCastDevice) return;
|
|
||||||
|
|
||||||
// If switching mode, stop current playback
|
|
||||||
if (isPlaying) {
|
|
||||||
await stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deviceName) {
|
|
||||||
currentMode = 'cast';
|
|
||||||
currentCastDevice = deviceName;
|
|
||||||
castBtn.style.color = 'var(--success)';
|
|
||||||
} else {
|
|
||||||
currentMode = 'local';
|
|
||||||
currentCastDevice = null;
|
|
||||||
castBtn.style.color = 'var(--text-main)';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUI();
|
|
||||||
|
|
||||||
// Auto-play if we were playing? Let's stay stopped to be safe/explicit
|
|
||||||
// Or auto-play for better UX?
|
|
||||||
// Let's prompt user to play.
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', init);
|
|
||||||
|
|
||||||
// Open overlay and show list of stations (used by menu/hamburger)
|
|
||||||
function openStationsOverlay() {
|
|
||||||
castOverlay.classList.remove('hidden');
|
|
||||||
castOverlay.setAttribute('aria-hidden', 'false');
|
|
||||||
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Loading...</div><div class="device-sub">Preparing stations</div></li>';
|
|
||||||
|
|
||||||
// If stations not loaded yet, show message
|
|
||||||
if (!stations || stations.length === 0) {
|
|
||||||
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceListEl.innerHTML = '';
|
|
||||||
|
|
||||||
stations.forEach((s, idx) => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'device' + (currentIndex === idx ? ' selected' : '');
|
|
||||||
const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || '');
|
|
||||||
li.innerHTML = `<div class="device-main">${s.name}</div><div class="device-sub">${subtitle}</div>`;
|
|
||||||
li.onclick = async () => {
|
|
||||||
// Always switch to local playback when selecting from stations menu
|
|
||||||
currentMode = 'local';
|
|
||||||
currentCastDevice = null;
|
|
||||||
castBtn.style.color = 'var(--text-main)';
|
|
||||||
|
|
||||||
// Select and play
|
|
||||||
currentIndex = idx;
|
|
||||||
loadStation(currentIndex);
|
|
||||||
closeCastOverlay();
|
|
||||||
try {
|
|
||||||
await play();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to play station from menu', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
deviceListEl.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,622 +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: 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
-webkit-app-region: drag; /* Draggable area */
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 220px;
|
|
||||||
height: 220px;
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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-bottom: 30px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
width: 100%; /* Live always full or pulsing */
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--accent), #fff);
|
|
||||||
border-radius: 2px;
|
|
||||||
opacity: 0.8;
|
|
||||||
box-shadow: 0 0 10px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-handle {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(50%, -50%);
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 10px rgba(255,255,255,0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Controls */
|
|
||||||
.controls-section {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 30px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-main);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s, opacity 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn:active {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.secondary {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.primary {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05));
|
|
||||||
border: 1px solid rgba(255,255,255,0.3);
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2), inset 0 0 10px rgba(255,255,255,0.1);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.primary svg {
|
|
||||||
filter: drop-shadow(0 0 5px var(--accent-glow));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Playing state - pulsing glow ring */
|
|
||||||
.control-btn.primary.playing {
|
|
||||||
animation: pulse-ring 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
|
|
||||||
inset 0 0 10px rgba(255,255,255,0.1),
|
|
||||||
0 0 0 0 rgba(223, 166, 255, 0.7);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2),
|
|
||||||
inset 0 0 10px rgba(255,255,255,0.1),
|
|
||||||
0 0 0 8px rgba(223, 166, 255, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon container prevents layout jump */
|
|
||||||
.icon-container {
|
|
||||||
position: relative;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-container svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Volume */
|
|
||||||
.volume-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-container {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=range] {
|
|
||||||
width: 100%;
|
|
||||||
background: transparent;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=range]::-webkit-slider-runnable-track {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=range]::-webkit-slider-thumb {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ffffff;
|
|
||||||
cursor: pointer;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin-top: -6px; /* align with track */
|
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#volume-value {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
width: 30px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn.small {
|
|
||||||
padding: 0;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cast Overlay (Beautified as per layout2_plan.md) */
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(20, 10, 35, 0.45);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay:not(.hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal {
|
|
||||||
width: min(420px, calc(100vw - 48px));
|
|
||||||
padding: 22px;
|
|
||||||
border-radius: 22px;
|
|
||||||
background: rgba(30, 30, 40, 0.82);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
box-shadow: 0 30px 80px rgba(0,0,0,0.6);
|
|
||||||
color: #fff;
|
|
||||||
animation: pop 0.22s ease;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pop {
|
|
||||||
from { transform: scale(0.94); opacity: 0; }
|
|
||||||
to { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h2 {
|
|
||||||
margin: 0 0 14px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device list */
|
|
||||||
.device-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 10px 5px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
max-height: 360px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device row */
|
|
||||||
.device {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device:hover {
|
|
||||||
background: rgba(255,255,255,0.10);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device .device-main {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device .device-sub {
|
|
||||||
margin-top: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.7;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selected device */
|
|
||||||
.device.selected {
|
|
||||||
background: linear-gradient(135deg, #c77dff, #8b5cf6);
|
|
||||||
box-shadow: 0 0 18px rgba(199,125,255,0.65);
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device.selected .device-main,
|
|
||||||
.device.selected .device-sub {
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device.selected .device-sub {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cancel button */
|
|
||||||
.btn.cancel {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
background: #d16b7d;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.15s ease, background 0.2s;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.cancel:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
background: #e17c8d;
|
|
||||||
}
|
|
||||||
91
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"npx": "^3.0.0",
|
"npx": "^3.0.0",
|
||||||
"rcedit": "^1.1.2"
|
"rcedit": "^1.1.2"
|
||||||
}
|
}
|
||||||
@@ -255,6 +256,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "src/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.14",
|
||||||
|
"npm": ">=6",
|
||||||
|
"yarn": ">=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
@@ -303,6 +338,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -1661,6 +1703,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rcedit": {
|
"node_modules/rcedit": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rcedit/-/rcedit-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rcedit/-/rcedit-1.1.2.tgz",
|
||||||
@@ -1682,6 +1734,45 @@
|
|||||||
"rimraf": "bin.js"
|
"rimraf": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
11
package.json
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "radio-tauri",
|
"name": "radio-tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"build": "node tools/copy-binaries.js && tauri build && node tools/post-build-rcedit.js",
|
"dev:native": "node tools/copy-binaries.js && node tools/copy-ffmpeg.js && tauri dev",
|
||||||
"tauri": "node tools/copy-binaries.js && tauri"
|
"ffmpeg:download": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/download-ffmpeg.ps1",
|
||||||
|
"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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"npx": "^3.0.0",
|
"npx": "^3.0.0",
|
||||||
"rcedit": "^1.1.2"
|
"rcedit": "^1.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
|
||||||
<stop offset="0" stop-color="#7b7fd8"/>
|
|
||||||
<stop offset="1" stop-color="#b57cf2"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" rx="24" fill="url(#g)" />
|
|
||||||
<g fill="white" transform="translate(32,32)">
|
|
||||||
<circle cx="48" cy="48" r="28" fill="rgba(255,255,255,0.15)" />
|
|
||||||
<path d="M24 48c6-10 16-16 24-16v8c-6 0-14 4-18 12s-2 12 0 12 6-2 10-6c4-4 10-6 14-6v8c-6 0-14 4-18 12s-2 12 0 12" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" />
|
|
||||||
<text x="96" y="98" font-family="sans-serif" font-size="18" fill="white" opacity="0.95">Radio</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 815 B |
@@ -1,27 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>Radio Player</title>
|
|
||||||
|
|
||||||
<!-- Google Cast Receiver SDK -->
|
|
||||||
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<h1>Radio Player</h1>
|
|
||||||
<p id="status">Ready</p>
|
|
||||||
|
|
||||||
<div id="artwork">
|
|
||||||
<img src="assets/logo.svg" alt="Radio Player" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="station">Radio – Live Stream</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="receiver.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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' });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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)."
|
|
||||||
71
scripts/download-ffmpeg.ps1
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
param(
|
||||||
|
[string]$Url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
|
||||||
|
[string]$OutDir = "tools/ffmpeg/bin",
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$isWindows = $env:OS -eq 'Windows_NT'
|
||||||
|
if (-not $isWindows) {
|
||||||
|
Write-Host "This script is intended for Windows (ffmpeg.exe)." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
$outDirAbs = (Resolve-Path (Join-Path $repoRoot $OutDir) -ErrorAction SilentlyContinue)
|
||||||
|
if (-not $outDirAbs) {
|
||||||
|
$outDirAbs = Join-Path $repoRoot $OutDir
|
||||||
|
New-Item -ItemType Directory -Force -Path $outDirAbs | Out-Null
|
||||||
|
} else {
|
||||||
|
$outDirAbs = $outDirAbs.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$ffmpegDest = Join-Path $outDirAbs "ffmpeg.exe"
|
||||||
|
|
||||||
|
# If already present, do nothing.
|
||||||
|
if (Test-Path $ffmpegDest) {
|
||||||
|
Write-Host "FFmpeg already present: $ffmpegDest"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "Dry run:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Would download: $Url"
|
||||||
|
Write-Host " Would install to: $ffmpegDest"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "About to download a prebuilt FFmpeg package:" -ForegroundColor Cyan
|
||||||
|
Write-Host " $Url"
|
||||||
|
Write-Host "You are responsible for reviewing the FFmpeg license/compliance for your use case." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$tempRoot = Join-Path $env:TEMP ("radioplayer-ffmpeg-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
$zipPath = Join-Path $tempRoot "ffmpeg.zip"
|
||||||
|
$extractDir = Join-Path $tempRoot "extract"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "Downloading..." -ForegroundColor Cyan
|
||||||
|
Invoke-WebRequest -Uri $Url -OutFile $zipPath -UseBasicParsing
|
||||||
|
|
||||||
|
Write-Host "Extracting..." -ForegroundColor Cyan
|
||||||
|
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
|
||||||
|
|
||||||
|
$candidate = Get-ChildItem -Path $extractDir -Recurse -Filter "ffmpeg.exe" | Where-Object {
|
||||||
|
$_.FullName -match "\\bin\\ffmpeg\.exe$"
|
||||||
|
} | Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $candidate) {
|
||||||
|
throw "Could not find ffmpeg.exe under extracted content. The archive layout may have changed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -Force -Path $candidate.FullName -Destination $ffmpegDest
|
||||||
|
|
||||||
|
Write-Host "Installed FFmpeg to: $ffmpegDest" -ForegroundColor Green
|
||||||
|
Write-Host "Next: run 'node tools/copy-ffmpeg.js' (or 'npm run dev:native' / 'npm run build') to bundle it into src-tauri/resources/." -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item -Recurse -Force -Path $tempRoot -ErrorAction SilentlyContinue } catch {}
|
||||||
|
}
|
||||||
287
src-tauri/Cargo.lock
generated
@@ -32,6 +32,28 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -247,6 +269,24 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bindgen"
|
||||||
|
version = "0.72.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cexpr",
|
||||||
|
"clang-sys",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"rustc-hash",
|
||||||
|
"shlex",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -426,6 +466,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfb"
|
name = "cfb"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -471,6 +520,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clang-sys"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||||
|
dependencies = [
|
||||||
|
"glob",
|
||||||
|
"libc",
|
||||||
|
"libloading 0.8.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.57"
|
version = "0.1.57"
|
||||||
@@ -565,6 +625,49 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"coreaudio-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-sys"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.15.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||||
|
dependencies = [
|
||||||
|
"alsa",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"coreaudio-rs",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"ndk 0.8.0",
|
||||||
|
"ndk-context",
|
||||||
|
"oboe",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.54.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -680,6 +783,12 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -1898,6 +2007,15 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -2040,7 +2158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"libloading",
|
"libloading 0.7.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2060,6 +2178,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libloading"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -2109,6 +2237,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -2176,6 +2313,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2236,6 +2379,20 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"jni-sys",
|
||||||
|
"log",
|
||||||
|
"ndk-sys 0.5.0+25.2.9519653",
|
||||||
|
"num_enum",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2245,7 +2402,7 @@ dependencies = [
|
|||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"jni-sys",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -2257,6 +2414,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-sys"
|
||||||
|
version = "0.5.0+25.2.9519653"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk-sys"
|
name = "ndk-sys"
|
||||||
version = "0.6.0+11769913"
|
version = "0.6.0+11769913"
|
||||||
@@ -2291,12 +2457,33 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2540,6 +2727,29 @@ dependencies = [
|
|||||||
"objc2-security",
|
"objc2-security",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oboe"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||||
|
dependencies = [
|
||||||
|
"jni",
|
||||||
|
"ndk 0.8.0",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"oboe-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oboe-sys"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -3072,10 +3282,13 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "radio-tauri"
|
name = "radio-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"cpal",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
|
"ringbuf",
|
||||||
"rust_cast",
|
"rust_cast",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3335,6 +3548,15 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ringbuf"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust_cast"
|
name = "rust_cast"
|
||||||
version = "0.19.0"
|
version = "0.19.0"
|
||||||
@@ -3352,6 +3574,12 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3922,7 +4150,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -4134,9 +4362,9 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
@@ -4147,7 +4375,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -4218,7 +4446,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4319,7 +4547,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4366,7 +4594,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4392,7 +4620,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5158,7 +5386,7 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -5182,7 +5410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
|
checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5244,6 +5472,16 @@ dependencies = [
|
|||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.54.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -5266,6 +5504,16 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.1.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -5347,6 +5595,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5769,7 +6026,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"kuchikiki",
|
"kuchikiki",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -5787,7 +6044,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "radio-tauri"
|
name = "radio-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -27,4 +27,7 @@ mdns-sd = "0.17.1"
|
|||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.3"
|
||||||
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
cpal = "0.15"
|
||||||
|
ringbuf = "0.3"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream, UdpSocket};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -8,6 +18,10 @@ use tauri::{AppHandle, Manager, State};
|
|||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use reqwest;
|
use reqwest;
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
|
||||||
|
mod player;
|
||||||
|
use player::{PlayerCommand, PlayerController, PlayerShared, PlayerState};
|
||||||
|
|
||||||
struct SidecarState {
|
struct SidecarState {
|
||||||
child: Mutex<Option<CommandChild>>,
|
child: Mutex<Option<CommandChild>>,
|
||||||
@@ -17,6 +31,322 @@ struct AppState {
|
|||||||
known_devices: Mutex<HashMap<String, String>>,
|
known_devices: Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CastProxy {
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CastProxyState {
|
||||||
|
inner: Mutex<Option<CastProxy>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct CastProxyStartResult {
|
||||||
|
url: String,
|
||||||
|
// "tap" | "proxy"
|
||||||
|
mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native (non-WebView) audio player state.
|
||||||
|
// Step 1: state machine + command interface only (no decoding/output yet).
|
||||||
|
struct PlayerRuntime {
|
||||||
|
shared: &'static PlayerShared,
|
||||||
|
controller: PlayerController,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp01(v: f32) -> f32 {
|
||||||
|
if v.is_nan() {
|
||||||
|
0.0
|
||||||
|
} else if v < 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if v > 1.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_http_host(ip: IpAddr) -> String {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => v4.to_string(),
|
||||||
|
IpAddr::V6(v6) => format!("[{v6}]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_ip_for_peer(peer_ip: IpAddr) -> Result<IpAddr, String> {
|
||||||
|
// Trick: connect a UDP socket to the peer and read the chosen local address.
|
||||||
|
// Port number is irrelevant; no packets are sent for UDP connect().
|
||||||
|
let peer = SocketAddr::new(peer_ip, 9);
|
||||||
|
let bind_addr = match peer_ip {
|
||||||
|
IpAddr::V4(_) => "0.0.0.0:0",
|
||||||
|
IpAddr::V6(_) => "[::]:0",
|
||||||
|
};
|
||||||
|
let sock = UdpSocket::bind(bind_addr).map_err(|e| e.to_string())?;
|
||||||
|
sock.connect(peer).map_err(|e| e.to_string())?;
|
||||||
|
Ok(sock.local_addr().map_err(|e| e.to_string())?.ip())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_listen(ip: IpAddr, port: u16) {
|
||||||
|
// Best-effort: give ffmpeg a moment to bind before we tell the Chromecast.
|
||||||
|
let addr = SocketAddr::new(ip, port);
|
||||||
|
for _ in 0..50 {
|
||||||
|
if TcpStream::connect_timeout(&addr, Duration::from_millis(30)).is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_cast_proxy_locked(lock: &mut Option<CastProxy>) {
|
||||||
|
if let Some(mut proxy) = lock.take() {
|
||||||
|
let _ = proxy.child.kill();
|
||||||
|
let _ = proxy.child.wait();
|
||||||
|
println!("Cast proxy stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_standalone_cast_proxy(url: String, port: u16) -> Result<Child, String> {
|
||||||
|
// Standalone path (fallback): FFmpeg pulls the station URL and serves MP3 over HTTP.
|
||||||
|
// Try libmp3lame first, then fall back to the built-in "mp3" encoder if needed.
|
||||||
|
let ffmpeg = player::ffmpeg_command();
|
||||||
|
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
||||||
|
|
||||||
|
let spawn = |codec: &str| -> Result<Child, String> {
|
||||||
|
let mut cmd = Command::new(&ffmpeg);
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
cmd
|
||||||
|
.arg("-nostdin")
|
||||||
|
.arg("-hide_banner")
|
||||||
|
.arg("-loglevel")
|
||||||
|
.arg("warning")
|
||||||
|
.arg("-reconnect")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-reconnect_streamed")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-reconnect_delay_max")
|
||||||
|
.arg("5")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(&url)
|
||||||
|
.arg("-vn")
|
||||||
|
.arg("-c:a")
|
||||||
|
.arg(codec)
|
||||||
|
.arg("-b:a")
|
||||||
|
.arg("128k")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("mp3")
|
||||||
|
.arg("-content_type")
|
||||||
|
.arg("audio/mpeg")
|
||||||
|
.arg("-listen")
|
||||||
|
.arg("1")
|
||||||
|
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to start ffmpeg cast proxy ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut child = spawn("libmp3lame")?;
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
if let Ok(Some(status)) = child.try_wait() {
|
||||||
|
if !status.success() {
|
||||||
|
eprintln!("Standalone cast proxy exited early; retrying with -c:a mp3");
|
||||||
|
child = spawn("mp3")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cast_proxy_start(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
proxy_state: State<'_, CastProxyState>,
|
||||||
|
player: State<'_, PlayerRuntime>,
|
||||||
|
device_name: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<CastProxyStartResult, String> {
|
||||||
|
// Make sure ffmpeg exists before we try to cast.
|
||||||
|
player::preflight_ffmpeg_only()?;
|
||||||
|
|
||||||
|
let device_ip_str = {
|
||||||
|
let devices = state.known_devices.lock().unwrap();
|
||||||
|
devices
|
||||||
|
.get(&device_name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Device not found")?
|
||||||
|
};
|
||||||
|
let device_ip: IpAddr = device_ip_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("Invalid device IP: {device_ip_str}"))?;
|
||||||
|
let local_ip = local_ip_for_peer(device_ip)?;
|
||||||
|
|
||||||
|
// Pick an ephemeral port.
|
||||||
|
let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
|
||||||
|
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let host = format_http_host(local_ip);
|
||||||
|
let proxy_url = format!("http://{host}:{port}/stream.mp3");
|
||||||
|
|
||||||
|
// Stop any existing standalone proxy first.
|
||||||
|
{
|
||||||
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
|
stop_cast_proxy_locked(&mut lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer reusing the native decoder PCM when possible.
|
||||||
|
// If the currently playing URL differs (or nothing is playing), start a headless decoder.
|
||||||
|
let snapshot = player.shared.snapshot();
|
||||||
|
let is_same_url = snapshot.url.as_deref() == Some(url.as_str());
|
||||||
|
let is_decoding = matches!(snapshot.status, player::PlayerStatus::Playing | player::PlayerStatus::Buffering);
|
||||||
|
if !(is_same_url && is_decoding) {
|
||||||
|
player
|
||||||
|
.controller
|
||||||
|
.tx
|
||||||
|
.send(PlayerCommand::PlayCast { url: url.clone() })
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
|
||||||
|
let _ = player
|
||||||
|
.controller
|
||||||
|
.tx
|
||||||
|
.send(PlayerCommand::CastTapStart {
|
||||||
|
port,
|
||||||
|
reply: reply_tx,
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match reply_rx.recv_timeout(Duration::from_secs(2)) {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
wait_for_listen(local_ip, port);
|
||||||
|
Ok(CastProxyStartResult {
|
||||||
|
url: proxy_url,
|
||||||
|
mode: "tap".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
eprintln!("Cast tap start failed; falling back to standalone proxy: {e}");
|
||||||
|
let mut child = spawn_standalone_cast_proxy(url, port)?;
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
for line in reader.lines().flatten() {
|
||||||
|
eprintln!("[cast-proxy ffmpeg] {line}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wait_for_listen(local_ip, port);
|
||||||
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
|
*lock = Some(CastProxy { child });
|
||||||
|
Ok(CastProxyStartResult {
|
||||||
|
url: proxy_url,
|
||||||
|
mode: "proxy".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("Cast tap start timed out; falling back to standalone proxy");
|
||||||
|
let mut child = spawn_standalone_cast_proxy(url, port)?;
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
for line in reader.lines().flatten() {
|
||||||
|
eprintln!("[cast-proxy ffmpeg] {line}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wait_for_listen(local_ip, port);
|
||||||
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
|
*lock = Some(CastProxy { child });
|
||||||
|
Ok(CastProxyStartResult {
|
||||||
|
url: proxy_url,
|
||||||
|
mode: "proxy".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cast_proxy_stop(proxy_state: State<'_, CastProxyState>, player: State<'_, PlayerRuntime>) -> Result<(), String> {
|
||||||
|
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
|
||||||
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
|
stop_cast_proxy_locked(&mut lock);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn player_get_state(player: State<'_, PlayerRuntime>) -> Result<PlayerState, String> {
|
||||||
|
Ok(player.shared.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn player_set_volume(
|
||||||
|
player: State<'_, PlayerRuntime>,
|
||||||
|
volume: f32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let volume = clamp01(volume);
|
||||||
|
{
|
||||||
|
let mut s = player.shared.state.lock().unwrap();
|
||||||
|
s.volume = volume;
|
||||||
|
}
|
||||||
|
player
|
||||||
|
.controller
|
||||||
|
.tx
|
||||||
|
.send(PlayerCommand::SetVolume { volume })
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn player_play(player: State<'_, PlayerRuntime>, url: String) -> Result<(), String> {
|
||||||
|
// Fail fast if audio output or ffmpeg is not available.
|
||||||
|
// This keeps UX predictable: JS can show an error without flipping to "playing".
|
||||||
|
if let Err(e) = player::preflight_check() {
|
||||||
|
{
|
||||||
|
let mut s = player.shared.state.lock().unwrap();
|
||||||
|
s.status = player::PlayerStatus::Error;
|
||||||
|
s.error = Some(e.clone());
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = player.shared.state.lock().unwrap();
|
||||||
|
s.error = None;
|
||||||
|
s.url = Some(url.clone());
|
||||||
|
// Step 1: report buffering immediately; the engine thread will progress.
|
||||||
|
s.status = player::PlayerStatus::Buffering;
|
||||||
|
}
|
||||||
|
|
||||||
|
player
|
||||||
|
.controller
|
||||||
|
.tx
|
||||||
|
.send(PlayerCommand::Play { url })
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn player_stop(player: State<'_, PlayerRuntime>) -> Result<(), String> {
|
||||||
|
{
|
||||||
|
let mut s = player.shared.state.lock().unwrap();
|
||||||
|
s.error = None;
|
||||||
|
s.status = player::PlayerStatus::Stopped;
|
||||||
|
}
|
||||||
|
player
|
||||||
|
.controller
|
||||||
|
.tx
|
||||||
|
.send(PlayerCommand::Stop)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn list_cast_devices(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
async fn list_cast_devices(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||||
let devices = state.known_devices.lock().unwrap();
|
let devices = state.known_devices.lock().unwrap();
|
||||||
@@ -87,8 +417,18 @@ async fn cast_play(
|
|||||||
async fn cast_stop(
|
async fn cast_stop(
|
||||||
_app: AppHandle,
|
_app: AppHandle,
|
||||||
sidecar_state: State<'_, SidecarState>,
|
sidecar_state: State<'_, SidecarState>,
|
||||||
|
proxy_state: State<'_, CastProxyState>,
|
||||||
|
player: State<'_, PlayerRuntime>,
|
||||||
_device_name: String,
|
_device_name: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
{
|
||||||
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
|
stop_cast_proxy_locked(&mut lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety net: stop any active tap too.
|
||||||
|
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
|
||||||
|
|
||||||
let mut lock = sidecar_state.child.lock().unwrap();
|
let mut lock = sidecar_state.child.lock().unwrap();
|
||||||
if let Some(ref mut child) = *lock {
|
if let Some(ref mut child) = *lock {
|
||||||
let stop_cmd = json!({ "command": "stop", "args": {} });
|
let stop_cmd = json!({ "command": "stop", "args": {} });
|
||||||
@@ -134,11 +474,72 @@ async fn fetch_url(_app: AppHandle, url: String) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_image_data_url(url: String) -> Result<String, String> {
|
||||||
|
// Fetch remote images via backend and return a data: URL.
|
||||||
|
// This helps when WebView blocks http images (mixed-content) or some hosts block hotlinking.
|
||||||
|
let parsed = reqwest::Url::parse(&url).map_err(|e| e.to_string())?;
|
||||||
|
match parsed.scheme() {
|
||||||
|
"http" | "https" => {}
|
||||||
|
_ => return Err("Only http/https URLs are allowed".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.get(parsed)
|
||||||
|
.header(reqwest::header::USER_AGENT, "RadioPlayer/1.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("HTTP {} while fetching image", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_type = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
|
||||||
|
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||||
|
const MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
if bytes.len() > MAX_BYTES {
|
||||||
|
return Err("Image too large".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Be conservative: prefer image/* content types, but allow svg even if mislabelled.
|
||||||
|
let looks_like_image = content_type.starts_with("image/")
|
||||||
|
|| content_type == "application/svg+xml"
|
||||||
|
|| url.to_lowercase().ends_with(".svg");
|
||||||
|
if !looks_like_image {
|
||||||
|
return Err(format!("Not an image content-type: {}", content_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let b64 = general_purpose::STANDARD.encode(bytes);
|
||||||
|
Ok(format!("data:{};base64,{}", content_type, b64))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
// Ensure native audio shuts down on app close.
|
||||||
|
// We do not prevent the close; this is best-effort cleanup.
|
||||||
|
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
|
||||||
|
let player = window.app_handle().state::<PlayerRuntime>();
|
||||||
|
let _ = player.controller.tx.send(PlayerCommand::Shutdown);
|
||||||
|
|
||||||
|
// Also stop any active cast tap/proxy so we don't leave processes behind.
|
||||||
|
let _ = player.controller.tx.send(PlayerCommand::CastTapStop);
|
||||||
|
let proxy_state = window.app_handle().state::<CastProxyState>();
|
||||||
|
let mut lock = proxy_state.inner.lock().unwrap();
|
||||||
|
stop_cast_proxy_locked(&mut lock);
|
||||||
|
}
|
||||||
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
known_devices: Mutex::new(HashMap::new()),
|
known_devices: Mutex::new(HashMap::new()),
|
||||||
@@ -146,6 +547,18 @@ pub fn run() {
|
|||||||
app.manage(SidecarState {
|
app.manage(SidecarState {
|
||||||
child: Mutex::new(None),
|
child: Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
app.manage(CastProxyState {
|
||||||
|
inner: Mutex::new(None),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player scaffolding: leak shared state to get a 'static reference for the
|
||||||
|
// long-running thread without complex lifetime plumbing.
|
||||||
|
// Later refactors can move this to Arc<...> when the engine grows.
|
||||||
|
let shared: &'static PlayerShared = Box::leak(Box::new(PlayerShared {
|
||||||
|
state: Mutex::new(PlayerState::default()),
|
||||||
|
}));
|
||||||
|
let controller = player::spawn_player_thread(shared);
|
||||||
|
app.manage(PlayerRuntime { shared, controller });
|
||||||
|
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -188,8 +601,17 @@ pub fn run() {
|
|||||||
cast_play,
|
cast_play,
|
||||||
cast_stop,
|
cast_stop,
|
||||||
cast_set_volume,
|
cast_set_volume,
|
||||||
|
cast_proxy_start,
|
||||||
|
cast_proxy_stop,
|
||||||
// allow frontend to request arbitrary URLs via backend (bypass CORS)
|
// allow frontend to request arbitrary URLs via backend (bypass CORS)
|
||||||
fetch_url
|
fetch_url,
|
||||||
|
// fetch remote images via backend (data: URL), helps with mixed-content
|
||||||
|
fetch_image_data_url,
|
||||||
|
// native player commands (step 1 scaffold)
|
||||||
|
player_play,
|
||||||
|
player_stop,
|
||||||
|
player_set_volume,
|
||||||
|
player_get_state
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
779
src-tauri/src/player.rs
Normal file
@@ -0,0 +1,779 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||||
|
mpsc, Arc, Mutex,
|
||||||
|
};
|
||||||
|
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 {
|
||||||
|
Idle,
|
||||||
|
Buffering,
|
||||||
|
Playing,
|
||||||
|
Stopped,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PlayerState {
|
||||||
|
pub status: PlayerStatus,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub volume: f32,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlayerState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
status: PlayerStatus::Idle,
|
||||||
|
url: None,
|
||||||
|
volume: 0.5,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlayerShared {
|
||||||
|
pub state: Mutex<PlayerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerShared {
|
||||||
|
pub fn snapshot(&self) -> PlayerState {
|
||||||
|
self.state.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Result<(), String>>,
|
||||||
|
},
|
||||||
|
CastTapStop,
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PlayerController {
|
||||||
|
pub tx: mpsc::Sender<PlayerCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_player_thread(shared: &'static PlayerShared) -> PlayerController {
|
||||||
|
let (tx, rx) = mpsc::channel::<PlayerCommand>();
|
||||||
|
|
||||||
|
std::thread::spawn(move || player_thread(shared, rx));
|
||||||
|
PlayerController { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp01(v: f32) -> f32 {
|
||||||
|
if v.is_nan() {
|
||||||
|
0.0
|
||||||
|
} else if v < 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if v > 1.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume_to_bits(v: f32) -> u32 {
|
||||||
|
clamp01(v).to_bits()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume_from_bits(bits: u32) -> f32 {
|
||||||
|
f32::from_bits(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(shared: &'static PlayerShared, status: PlayerStatus) {
|
||||||
|
let mut s = shared.state.lock().unwrap();
|
||||||
|
if s.status != status {
|
||||||
|
s.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error(shared: &'static PlayerShared, message: String) {
|
||||||
|
let mut s = shared.state.lock().unwrap();
|
||||||
|
s.status = PlayerStatus::Error;
|
||||||
|
s.error = Some(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ffmpeg_command() -> OsString {
|
||||||
|
// Step 2: external ffmpeg binary.
|
||||||
|
// Lookup order:
|
||||||
|
// 1) RADIOPLAYER_FFMPEG (absolute or relative)
|
||||||
|
// 2) ffmpeg next to the application executable
|
||||||
|
// 3) PATH lookup (ffmpeg / ffmpeg.exe)
|
||||||
|
if let Ok(p) = std::env::var("RADIOPLAYER_FFMPEG") {
|
||||||
|
if !p.trim().is_empty() {
|
||||||
|
return OsString::from(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
// Common locations depending on bundler/platform.
|
||||||
|
let candidates = [
|
||||||
|
dir.join(local_name),
|
||||||
|
// Some packagers place resources in a sibling folder.
|
||||||
|
dir.join("resources").join(local_name),
|
||||||
|
dir.join("Resources").join(local_name),
|
||||||
|
// Or one level above.
|
||||||
|
dir.join("..").join("resources").join(local_name),
|
||||||
|
dir.join("..").join("Resources").join(local_name),
|
||||||
|
];
|
||||||
|
for candidate in candidates {
|
||||||
|
if candidate.exists() {
|
||||||
|
return candidate.into_os_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OsString::from(local_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preflight_ffmpeg_only() -> Result<(), String> {
|
||||||
|
let ffmpeg = ffmpeg_command();
|
||||||
|
let status = command_hidden(&ffmpeg)
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
||||||
|
format!(
|
||||||
|
"FFmpeg not available ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
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<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pipeline {
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
volume_bits: Arc<AtomicU32>,
|
||||||
|
_stream: Option<cpal::Stream>,
|
||||||
|
decoder_join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
cast_tx: Arc<Mutex<Option<mpsc::SyncSender<Vec<u8>>>>>,
|
||||||
|
cast_proc: Option<CastTapProc>,
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pipeline {
|
||||||
|
fn start(shared: &'static PlayerShared, url: String, mode: PipelineMode) -> Result<Self, String> {
|
||||||
|
let (device, sample_format, cfg, sample_rate, channels) = match mode {
|
||||||
|
PipelineMode::WithOutput => {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let 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 (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::<i16>::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({
|
||||||
|
let s = shared.state.lock().unwrap();
|
||||||
|
volume_to_bits(s.volume)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let cast_tx: Arc<Mutex<Option<mpsc::SyncSender<Vec<u8>>>>> = Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
|
// Decoder thread: spawns ffmpeg, reads PCM, writes into ring buffer.
|
||||||
|
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(channels as usize)
|
||||||
|
.saturating_div(4); // ~250ms
|
||||||
|
|
||||||
|
'outer: loop {
|
||||||
|
if stop_for_decoder.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_status(shared_for_decoder, PlayerStatus::Buffering);
|
||||||
|
|
||||||
|
let ffmpeg = ffmpeg_command();
|
||||||
|
let ffmpeg_disp = ffmpeg.to_string_lossy();
|
||||||
|
let mut child = match command_hidden(&ffmpeg)
|
||||||
|
.arg("-nostdin")
|
||||||
|
.arg("-hide_banner")
|
||||||
|
.arg("-loglevel")
|
||||||
|
.arg("warning")
|
||||||
|
// basic reconnect flags (best-effort; not all protocols honor these)
|
||||||
|
.arg("-reconnect")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-reconnect_streamed")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-reconnect_delay_max")
|
||||||
|
.arg("5")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(&decoder_url)
|
||||||
|
.arg("-vn")
|
||||||
|
.arg("-ac")
|
||||||
|
.arg(channels.to_string())
|
||||||
|
.arg("-ar")
|
||||||
|
.arg(sample_rate.to_string())
|
||||||
|
.arg("-f")
|
||||||
|
.arg("s16le")
|
||||||
|
.arg("pipe:1")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
// If ffmpeg isn't available, this is a hard failure.
|
||||||
|
set_error(
|
||||||
|
shared_for_decoder,
|
||||||
|
format!(
|
||||||
|
"Failed to start ffmpeg ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stdout = match child.stdout.take() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
set_error(shared_for_decoder, "ffmpeg stdout not available".to_string());
|
||||||
|
let _ = child.kill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
let mut leftover: Option<u8> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if stop_for_decoder.load(Ordering::SeqCst) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = match stdout.read(&mut buf) {
|
||||||
|
Ok(0) => 0,
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
// EOF / disconnect. Try to reconnect after backoff.
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
if stop_for_decoder.load(Ordering::SeqCst) {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
set_status(shared_for_decoder, PlayerStatus::Buffering);
|
||||||
|
std::thread::sleep(Duration::from_millis(backoff_ms));
|
||||||
|
backoff_ms = (backoff_ms * 2).min(5000);
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
if let Some(prod) = prod_opt.as_mut() {
|
||||||
|
let _ = prod.push(sample);
|
||||||
|
}
|
||||||
|
pushed_since_start += 1;
|
||||||
|
i = 1;
|
||||||
|
} else {
|
||||||
|
leftover = Some(b0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while i + 1 < n {
|
||||||
|
let sample = i16::from_le_bytes([buf[i], buf[i + 1]]);
|
||||||
|
if let Some(prod) = prod_opt.as_mut() {
|
||||||
|
let _ = prod.push(sample);
|
||||||
|
}
|
||||||
|
pushed_since_start += 1;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < n {
|
||||||
|
leftover = Some(buf[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to Playing once we've decoded a small buffer.
|
||||||
|
if pushed_since_start >= playing_threshold_samples {
|
||||||
|
set_status(shared_for_decoder, PlayerStatus::Playing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = if mode == PipelineMode::WithOutput {
|
||||||
|
let device = device.expect("device must exist for WithOutput");
|
||||||
|
let sample_format = sample_format.expect("sample_format must exist for WithOutput");
|
||||||
|
let cfg = cfg.expect("cfg must exist for WithOutput");
|
||||||
|
let mut cons = cons_opt.take().expect("cons must exist for WithOutput");
|
||||||
|
|
||||||
|
// Audio callback: drain ring buffer and write to output.
|
||||||
|
let shared_for_cb = shared;
|
||||||
|
let stop_for_cb = Arc::clone(&stop_flag);
|
||||||
|
let volume_for_cb = Arc::clone(&volume_bits);
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
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<std::process::Child, String> {
|
||||||
|
command_hidden(&ffmpeg)
|
||||||
|
.arg("-nostdin")
|
||||||
|
.arg("-hide_banner")
|
||||||
|
.arg("-loglevel")
|
||||||
|
.arg("warning")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("s16le")
|
||||||
|
.arg("-ac")
|
||||||
|
.arg(channels.to_string())
|
||||||
|
.arg("-ar")
|
||||||
|
.arg(sample_rate.to_string())
|
||||||
|
.arg("-i")
|
||||||
|
.arg("pipe:0")
|
||||||
|
.arg("-vn")
|
||||||
|
.arg("-c:a")
|
||||||
|
.arg(codec)
|
||||||
|
.arg("-b:a")
|
||||||
|
.arg("128k")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("mp3")
|
||||||
|
.arg("-content_type")
|
||||||
|
.arg("audio/mpeg")
|
||||||
|
.arg("-listen")
|
||||||
|
.arg("1")
|
||||||
|
.arg(format!("http://0.0.0.0:{port}/stream.mp3"))
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to start ffmpeg cast tap ({ffmpeg_disp}): {e}. Set RADIOPLAYER_FFMPEG, bundle ffmpeg next to the app, or install ffmpeg on PATH."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut child = spawn("libmp3lame")?;
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
if let Ok(Some(status)) = child.try_wait() {
|
||||||
|
if !status.success() {
|
||||||
|
// Some builds lack libmp3lame; fall back to built-in encoder.
|
||||||
|
child = spawn("mp3")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| "ffmpeg cast tap stdin not available".to_string())?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256);
|
||||||
|
*self.cast_tx.lock().unwrap() = Some(tx);
|
||||||
|
|
||||||
|
let writer_join = std::thread::spawn(move || {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut stdin = stdin;
|
||||||
|
while let Ok(chunk) = rx.recv() {
|
||||||
|
if chunk.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if stdin.write_all(&chunk).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = stdin.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.cast_proc = Some(CastTapProc {
|
||||||
|
child,
|
||||||
|
writer_join: Some(writer_join),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_cast_tap(&mut self) {
|
||||||
|
*self.cast_tx.lock().unwrap() = None;
|
||||||
|
if let Some(mut proc) = self.cast_proc.take() {
|
||||||
|
let _ = proc.child.kill();
|
||||||
|
let _ = proc.child.wait();
|
||||||
|
if let Some(j) = proc.writer_join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(mut self, shared: &'static PlayerShared) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
set_status(shared, PlayerStatus::Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&self, volume: f32) {
|
||||||
|
self.volume_bits.store(volume_to_bits(volume), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_thread(shared: &'static PlayerShared, rx: mpsc::Receiver<PlayerCommand>) {
|
||||||
|
// Step 2: FFmpeg decode + CPAL playback.
|
||||||
|
let mut pipeline: Option<Pipeline> = None;
|
||||||
|
let mut pipeline_cast_owned = false;
|
||||||
|
while let Ok(cmd) = rx.recv() {
|
||||||
|
match cmd {
|
||||||
|
PlayerCommand::Play { url } => {
|
||||||
|
if let Some(p) = pipeline.take() {
|
||||||
|
p.stop(shared);
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline_cast_owned = false;
|
||||||
|
|
||||||
|
{
|
||||||
|
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::WithOutput) {
|
||||||
|
Ok(p) => {
|
||||||
|
// Apply current volume to pipeline atomics.
|
||||||
|
let vol = { shared.state.lock().unwrap().volume };
|
||||||
|
p.set_volume(vol);
|
||||||
|
pipeline = Some(p);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error(shared, e);
|
||||||
|
pipeline = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerCommand::PlayCast { url } => {
|
||||||
|
if let Some(p) = pipeline.take() {
|
||||||
|
p.stop(shared);
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline_cast_owned = true;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = shared.state.lock().unwrap();
|
||||||
|
s.error = None;
|
||||||
|
s.url = Some(url.clone());
|
||||||
|
s.status = PlayerStatus::Buffering;
|
||||||
|
}
|
||||||
|
|
||||||
|
match Pipeline::start(shared, url, PipelineMode::Headless) {
|
||||||
|
Ok(p) => {
|
||||||
|
let vol = { shared.state.lock().unwrap().volume };
|
||||||
|
p.set_volume(vol);
|
||||||
|
pipeline = Some(p);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error(shared, e);
|
||||||
|
pipeline = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerCommand::Stop => {
|
||||||
|
if let Some(p) = pipeline.take() {
|
||||||
|
p.stop(shared);
|
||||||
|
} else {
|
||||||
|
let mut s = shared.state.lock().unwrap();
|
||||||
|
s.status = PlayerStatus::Stopped;
|
||||||
|
s.error = None;
|
||||||
|
}
|
||||||
|
pipeline_cast_owned = false;
|
||||||
|
}
|
||||||
|
PlayerCommand::SetVolume { volume } => {
|
||||||
|
let v = clamp01(volume);
|
||||||
|
{
|
||||||
|
let mut s = shared.state.lock().unwrap();
|
||||||
|
s.volume = v;
|
||||||
|
}
|
||||||
|
if let Some(p) = pipeline.as_ref() {
|
||||||
|
p.set_volume(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerCommand::CastTapStart { port, reply } => {
|
||||||
|
if let Some(p) = pipeline.as_mut() {
|
||||||
|
// Current pipeline sample format is always s16le.
|
||||||
|
let res = p.start_cast_tap(port, p.sample_rate, p.channels);
|
||||||
|
let _ = reply.send(res);
|
||||||
|
} else {
|
||||||
|
let _ = reply.send(Err("No active decoder pipeline".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerCommand::CastTapStop => {
|
||||||
|
if let Some(p) = pipeline.as_mut() {
|
||||||
|
p.stop_cast_tap();
|
||||||
|
}
|
||||||
|
if pipeline_cast_owned {
|
||||||
|
if let Some(p) = pipeline.take() {
|
||||||
|
p.stop(shared);
|
||||||
|
}
|
||||||
|
pipeline_cast_owned = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerCommand::Shutdown => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = pipeline.take() {
|
||||||
|
p.stop(shared);
|
||||||
|
} else {
|
||||||
|
set_status(shared, PlayerStatus::Stopped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "RadioPlayer",
|
"productName": "RadioPlayer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"identifier": "si.klevze.radioPlayer",
|
"identifier": "si.klevze.radioPlayer",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/RadioPlayer"
|
"binaries/RadioPlayer"
|
||||||
],
|
],
|
||||||
|
"resources": [
|
||||||
|
"resources/*"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="artwork-section">
|
<section class="artwork-section">
|
||||||
|
<div class="artwork-stack">
|
||||||
<div class="artwork-container">
|
<div class="artwork-container">
|
||||||
<div class="artwork-placeholder">
|
<div class="artwork-placeholder">
|
||||||
<!-- Gooey SVG filter for fluid blob blending -->
|
<!-- Gooey SVG filter for fluid blob blending -->
|
||||||
@@ -96,14 +97,19 @@
|
|||||||
<span class="blob b10"></span>
|
<span class="blob b10"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
|
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
||||||
|
<span class="station-logo-text">1</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coverflow-style station carousel under the artwork (drag or use arrows) -->
|
||||||
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
||||||
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
||||||
<div id="artwork-coverflow-stage" class="artwork-coverflow-stage" role="list" aria-label="Station icons"></div>
|
<div id="artwork-coverflow-stage" class="artwork-coverflow-stage" role="list" aria-label="Station icons"></div>
|
||||||
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station">›</button>
|
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station">›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="track-info">
|
<section class="track-info">
|
||||||
@@ -116,6 +122,7 @@
|
|||||||
<div id="status-indicator" class="status-indicator-wrap" aria-hidden="true">
|
<div id="status-indicator" class="status-indicator-wrap" aria-hidden="true">
|
||||||
<span class="status-dot"></span>
|
<span class="status-dot"></span>
|
||||||
<span id="status-text"></span>
|
<span id="status-text"></span>
|
||||||
|
<span id="engine-badge" class="engine-badge" title="Playback engine">FFMPEG</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
456
src/main.js
@@ -1,13 +1,21 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
const { getCurrentWindow } = window.__TAURI__.window;
|
const { getCurrentWindow } = window.__TAURI__.window;
|
||||||
|
|
||||||
|
// In Tauri, the WebView may block insecure (http) images as mixed-content.
|
||||||
|
// We can optionally fetch such images via backend and render as data: URLs.
|
||||||
|
const runningInTauri = !!(window.__TAURI__ && window.__TAURI__.core);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let stations = [];
|
let stations = [];
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
let isPlaying = false;
|
let isPlaying = false;
|
||||||
let currentMode = 'local'; // 'local' | 'cast'
|
let currentMode = 'local'; // 'local' | 'cast'
|
||||||
let currentCastDevice = null;
|
let currentCastDevice = null;
|
||||||
const audio = new Audio();
|
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.
|
||||||
|
let localPlayerPollId = null;
|
||||||
|
|
||||||
// UI Elements
|
// UI Elements
|
||||||
const stationNameEl = document.getElementById('station-name');
|
const stationNameEl = document.getElementById('station-name');
|
||||||
@@ -17,6 +25,7 @@ const nowArtistEl = document.getElementById('now-artist');
|
|||||||
const nowTitleEl = document.getElementById('now-title');
|
const nowTitleEl = document.getElementById('now-title');
|
||||||
const statusTextEl = document.getElementById('status-text');
|
const statusTextEl = document.getElementById('status-text');
|
||||||
const statusDotEl = document.querySelector('.status-dot');
|
const statusDotEl = document.querySelector('.status-dot');
|
||||||
|
const engineBadgeEl = document.getElementById('engine-badge');
|
||||||
const playBtn = document.getElementById('play-btn');
|
const playBtn = document.getElementById('play-btn');
|
||||||
const iconPlay = document.getElementById('icon-play');
|
const iconPlay = document.getElementById('icon-play');
|
||||||
const iconStop = document.getElementById('icon-stop');
|
const iconStop = document.getElementById('icon-stop');
|
||||||
@@ -32,6 +41,95 @@ const coverflowStageEl = document.getElementById('artwork-coverflow-stage');
|
|||||||
const coverflowPrevBtn = document.getElementById('artwork-prev');
|
const coverflowPrevBtn = document.getElementById('artwork-prev');
|
||||||
const coverflowNextBtn = document.getElementById('artwork-next');
|
const coverflowNextBtn = document.getElementById('artwork-next');
|
||||||
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
||||||
|
const logoTextEl = document.querySelector('.station-logo-text');
|
||||||
|
const logoImgEl = document.getElementById('station-logo-img');
|
||||||
|
|
||||||
|
function toHttpsIfHttp(url) {
|
||||||
|
if (!url || typeof url !== 'string') return '';
|
||||||
|
return url.startsWith('http://') ? ('https://' + url.slice('http://'.length)) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmpty(urls) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const u of urls) {
|
||||||
|
if (!u || typeof u !== 'string') continue;
|
||||||
|
const trimmed = u.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImgWithFallback(imgEl, urls, onFinalError) {
|
||||||
|
let dataFallbackUrls = [];
|
||||||
|
// Backward compatible signature; allow passing { dataFallbackUrls } as 4th param.
|
||||||
|
// (Implemented below via arguments inspection.)
|
||||||
|
|
||||||
|
if (arguments.length >= 4 && arguments[3] && typeof arguments[3] === 'object') {
|
||||||
|
const opt = arguments[3];
|
||||||
|
if (Array.isArray(opt.dataFallbackUrls)) dataFallbackUrls = opt.dataFallbackUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = uniqueNonEmpty(urls);
|
||||||
|
let i = 0;
|
||||||
|
let dataIdx = 0;
|
||||||
|
let triedData = false;
|
||||||
|
|
||||||
|
if (!imgEl || candidates.length === 0) {
|
||||||
|
if (imgEl) {
|
||||||
|
imgEl.onload = null;
|
||||||
|
imgEl.onerror = null;
|
||||||
|
imgEl.src = '';
|
||||||
|
}
|
||||||
|
if (onFinalError) onFinalError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryNext = () => {
|
||||||
|
if (i >= candidates.length) {
|
||||||
|
// If direct loads failed and we're in Tauri, try fetching via backend and set as data URL.
|
||||||
|
if (runningInTauri && !triedData && dataFallbackUrls && dataFallbackUrls.length > 0) {
|
||||||
|
triedData = true;
|
||||||
|
const dataCandidates = uniqueNonEmpty(dataFallbackUrls);
|
||||||
|
const tryData = () => {
|
||||||
|
if (dataIdx >= dataCandidates.length) {
|
||||||
|
if (onFinalError) onFinalError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = dataCandidates[dataIdx++];
|
||||||
|
invoke('fetch_image_data_url', { url: u })
|
||||||
|
.then((dataUrl) => {
|
||||||
|
// Once we have a data URL, we can stop the fallback chain.
|
||||||
|
imgEl.src = dataUrl;
|
||||||
|
})
|
||||||
|
.catch(() => tryData());
|
||||||
|
};
|
||||||
|
tryData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onFinalError) onFinalError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextUrl = candidates[i++];
|
||||||
|
imgEl.src = nextUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
imgEl.onload = () => {
|
||||||
|
// keep last successful src
|
||||||
|
};
|
||||||
|
imgEl.onerror = () => {
|
||||||
|
tryNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Some CDNs block referrers; this can improve logo load reliability.
|
||||||
|
try { imgEl.referrerPolicy = 'no-referrer'; } catch (e) {}
|
||||||
|
|
||||||
|
tryNext();
|
||||||
|
}
|
||||||
// Global error handlers to avoid silent white screen and show errors in UI
|
// Global error handlers to avoid silent white screen and show errors in UI
|
||||||
window.addEventListener('error', (ev) => {
|
window.addEventListener('error', (ev) => {
|
||||||
try {
|
try {
|
||||||
@@ -61,17 +159,99 @@ const usIndex = document.getElementById('us_index');
|
|||||||
// Init
|
// Init
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
|
// Helpful debug information for release builds so we can compare parity with dev.
|
||||||
|
console.group && console.group('RadioCast init');
|
||||||
|
console.log('runningInTauri:', runningInTauri);
|
||||||
|
try { console.log('location:', location.href); } catch (_) {}
|
||||||
|
try { console.log('userAgent:', navigator.userAgent); } catch (_) {}
|
||||||
|
try { console.log('platform:', navigator.platform); } catch (_) {}
|
||||||
|
try { console.log('RADIO_DEBUG_DEVTOOLS flag:', localStorage.getItem('RADIO_DEBUG_DEVTOOLS')); } catch (_) {}
|
||||||
|
|
||||||
|
// Always try to read build stamp if present (bundled by build scripts).
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/build-info.json', { cache: 'no-store' });
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
const bi = await resp.json();
|
||||||
|
console.log('build-info:', bi);
|
||||||
|
} else {
|
||||||
|
console.log('build-info: not present');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('build-info: failed to read');
|
||||||
|
}
|
||||||
|
|
||||||
restoreSavedVolume();
|
restoreSavedVolume();
|
||||||
await loadStations();
|
await loadStations();
|
||||||
|
try { console.log('stations loaded:', Array.isArray(stations) ? stations.length : typeof stations); } catch (_) {}
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
ensureArtworkPointerFallback();
|
ensureArtworkPointerFallback();
|
||||||
updateUI();
|
updateUI();
|
||||||
|
updateEngineBadge();
|
||||||
|
|
||||||
|
// Optionally open devtools in release builds for debugging parity with `tauri dev`.
|
||||||
|
// Enable by setting `localStorage.setItem('RADIO_DEBUG_DEVTOOLS', '1')` or by creating
|
||||||
|
// `src/build-info.json` with { debug: true } at build time (the `build:devlike` script does this).
|
||||||
|
try {
|
||||||
|
let shouldOpen = false;
|
||||||
|
try { if (localStorage && localStorage.getItem && localStorage.getItem('RADIO_DEBUG_DEVTOOLS') === '1') shouldOpen = true; } catch (_) {}
|
||||||
|
|
||||||
|
// Build-time flag file (created by tools/write-build-flag.js when running `build`/`build:devlike`).
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/build-info.json', { cache: 'no-store' });
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
const bi = await resp.json();
|
||||||
|
if (bi && bi.debug) shouldOpen = true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (shouldOpen) {
|
||||||
|
try {
|
||||||
|
const w = getCurrentWindow();
|
||||||
|
if (w && typeof w.openDevTools === 'function') {
|
||||||
|
w.openDevTools();
|
||||||
|
console.log('Opened devtools via build-info/localStorage flag');
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('Failed to open devtools:', e); }
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
console.groupEnd && console.groupEnd();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error during init', e);
|
console.error('Error during init', e);
|
||||||
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e));
|
if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e && e.message ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateEngineBadge() {
|
||||||
|
if (!engineBadgeEl) return;
|
||||||
|
|
||||||
|
// In this app:
|
||||||
|
// - Local playback uses the native backend (FFmpeg decode + CPAL output).
|
||||||
|
// - Cast mode plays via Chromecast.
|
||||||
|
const kind = currentMode === 'cast' ? 'cast' : 'ffmpeg';
|
||||||
|
const label = kind === 'cast' ? 'CAST' : 'FFMPEG';
|
||||||
|
const title = kind === 'cast' ? 'Google Cast playback' : 'Native playback (FFmpeg)';
|
||||||
|
|
||||||
|
const iconSvg = kind === 'cast'
|
||||||
|
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 16.1A5 5 0 0 1 5.9 20" />
|
||||||
|
<path d="M2 12.05A9 9 0 0 1 9.95 20" />
|
||||||
|
<path d="M2 8V6a14 14 0 0 1 14 14h-2" />
|
||||||
|
</svg>`
|
||||||
|
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 15V9" />
|
||||||
|
<path d="M8 19V5" />
|
||||||
|
<path d="M12 16V8" />
|
||||||
|
<path d="M16 18V6" />
|
||||||
|
<path d="M20 15V9" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
engineBadgeEl.innerHTML = `${iconSvg}<span>${label}</span>`;
|
||||||
|
engineBadgeEl.title = title;
|
||||||
|
engineBadgeEl.classList.remove('engine-ffmpeg', 'engine-cast', 'engine-html');
|
||||||
|
engineBadgeEl.classList.add(`engine-${kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Volume persistence
|
// Volume persistence
|
||||||
function saveVolumeToStorage(val) {
|
function saveVolumeToStorage(val) {
|
||||||
try {
|
try {
|
||||||
@@ -95,7 +275,9 @@ function restoreSavedVolume() {
|
|||||||
volumeSlider.value = String(saved);
|
volumeSlider.value = String(saved);
|
||||||
volumeValue.textContent = `${saved}%`;
|
volumeValue.textContent = `${saved}%`;
|
||||||
const decimals = saved / 100;
|
const decimals = saved / 100;
|
||||||
audio.volume = decimals;
|
|
||||||
|
// Keep backend player volume in sync (best-effort).
|
||||||
|
invoke('player_set_volume', { volume: decimals }).catch(() => {});
|
||||||
// If currently in cast mode and a device is selected, propagate volume
|
// If currently in cast mode and a device is selected, propagate volume
|
||||||
if (currentMode === 'cast' && currentCastDevice) {
|
if (currentMode === 'cast' && currentCastDevice) {
|
||||||
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }).catch(()=>{});
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals }).catch(()=>{});
|
||||||
@@ -103,6 +285,53 @@ function restoreSavedVolume() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopLocalPlayerStatePolling() {
|
||||||
|
if (localPlayerPollId) {
|
||||||
|
try { clearInterval(localPlayerPollId); } catch (e) {}
|
||||||
|
localPlayerPollId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLocalPlayerStatePolling() {
|
||||||
|
stopLocalPlayerStatePolling();
|
||||||
|
// Polling keeps the existing UI in sync with native buffering/reconnect.
|
||||||
|
localPlayerPollId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
if (!isPlaying || currentMode !== 'local') return;
|
||||||
|
const st = await invoke('player_get_state');
|
||||||
|
if (!st || !statusTextEl || !statusDotEl) return;
|
||||||
|
|
||||||
|
const status = String(st.status || '').toLowerCase();
|
||||||
|
if (status === 'buffering') {
|
||||||
|
statusTextEl.textContent = 'Buffering...';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
||||||
|
} else if (status === 'playing') {
|
||||||
|
statusTextEl.textContent = 'Playing';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--success)';
|
||||||
|
} else if (status === 'error') {
|
||||||
|
statusTextEl.textContent = st.error ? `Error: ${st.error}` : 'Error';
|
||||||
|
statusDotEl.style.backgroundColor = 'var(--danger)';
|
||||||
|
|
||||||
|
// Backend is no longer playing; reflect that in UX.
|
||||||
|
isPlaying = false;
|
||||||
|
stopLocalPlayerStatePolling();
|
||||||
|
updateUI();
|
||||||
|
} else if (status === 'stopped' || status === 'idle') {
|
||||||
|
isPlaying = false;
|
||||||
|
stopLocalPlayerStatePolling();
|
||||||
|
updateUI();
|
||||||
|
} else {
|
||||||
|
// idle/stopped: keep UI consistent with our isPlaying flag
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Don't spam; just surface a minimal indicator.
|
||||||
|
try {
|
||||||
|
if (statusTextEl) statusTextEl.textContent = 'Error';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStations() {
|
async function loadStations() {
|
||||||
try {
|
try {
|
||||||
// stop any existing pollers before reloading stations
|
// stop any existing pollers before reloading stations
|
||||||
@@ -199,20 +428,30 @@ function renderCoverflow() {
|
|||||||
item.className = 'coverflow-item';
|
item.className = 'coverflow-item';
|
||||||
item.dataset.idx = String(idx);
|
item.dataset.idx = String(idx);
|
||||||
|
|
||||||
const logoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
|
const rawLogoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || '';
|
||||||
if (logoUrl) {
|
const fallbackLabel = (s && s.name ? String(s.name) : '?').trim();
|
||||||
|
item.title = fallbackLabel;
|
||||||
|
|
||||||
|
if (rawLogoUrl) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.alt = `${s.name} logo`;
|
img.alt = `${s.name} logo`;
|
||||||
img.src = logoUrl;
|
|
||||||
img.addEventListener('error', () => {
|
// Try https first (avoids mixed-content blocks), then fall back to original.
|
||||||
|
const candidates = [
|
||||||
|
toHttpsIfHttp(rawLogoUrl),
|
||||||
|
rawLogoUrl,
|
||||||
|
];
|
||||||
|
|
||||||
|
setImgWithFallback(img, candidates, () => {
|
||||||
item.innerHTML = '';
|
item.innerHTML = '';
|
||||||
item.classList.add('fallback');
|
item.classList.add('fallback');
|
||||||
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
|
item.textContent = fallbackLabel;
|
||||||
});
|
}, { dataFallbackUrls: [rawLogoUrl] });
|
||||||
|
|
||||||
item.appendChild(img);
|
item.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
item.classList.add('fallback');
|
item.classList.add('fallback');
|
||||||
item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?';
|
item.textContent = fallbackLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click a card: if it's not selected, select it.
|
// Click a card: if it's not selected, select it.
|
||||||
@@ -245,12 +484,32 @@ function wireCoverflowInteractions() {
|
|||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
if (coverflowPrevBtn) coverflowPrevBtn.onclick = () => setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
|
// IMPORTANT: prevent the coverflow drag handler (pointer capture) from swallowing button clicks.
|
||||||
if (coverflowNextBtn) coverflowNextBtn.onclick = () => setStationByIndex((currentIndex + 1) % stations.length);
|
if (coverflowPrevBtn) {
|
||||||
|
coverflowPrevBtn.onpointerdown = (ev) => { try { ev.stopPropagation(); } catch (e) {} };
|
||||||
|
coverflowPrevBtn.onclick = (ev) => {
|
||||||
|
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) {}
|
||||||
|
setStationByIndex((currentIndex - 1 + stations.length) % stations.length);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (coverflowNextBtn) {
|
||||||
|
coverflowNextBtn.onpointerdown = (ev) => { try { ev.stopPropagation(); } catch (e) {} };
|
||||||
|
coverflowNextBtn.onclick = (ev) => {
|
||||||
|
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) {}
|
||||||
|
setStationByIndex((currentIndex + 1) % stations.length);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Pointer drag (mouse/touch)
|
// Pointer drag (mouse/touch)
|
||||||
host.onpointerdown = (ev) => {
|
host.onpointerdown = (ev) => {
|
||||||
if (!stations || stations.length <= 1) return;
|
if (!stations || stations.length <= 1) return;
|
||||||
|
|
||||||
|
// If the user clicked the arrow buttons, let the button handler run.
|
||||||
|
// Otherwise pointer capture can prevent the click from reaching the button.
|
||||||
|
try {
|
||||||
|
if (ev.target && ev.target.closest && ev.target.closest('.coverflow-arrow')) return;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
coverflowPointerId = ev.pointerId;
|
coverflowPointerId = ev.pointerId;
|
||||||
coverflowStartX = ev.clientX;
|
coverflowStartX = ev.clientX;
|
||||||
coverflowLastX = ev.clientX;
|
coverflowLastX = ev.clientX;
|
||||||
@@ -308,10 +567,17 @@ function updateCoverflowTransforms() {
|
|||||||
try {
|
try {
|
||||||
if (!coverflowStageEl) return;
|
if (!coverflowStageEl) return;
|
||||||
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
|
const items = coverflowStageEl.querySelectorAll('.coverflow-item');
|
||||||
|
const n = stations ? stations.length : 0;
|
||||||
|
if (n <= 0) return;
|
||||||
const maxVisible = 3;
|
const maxVisible = 3;
|
||||||
items.forEach((el) => {
|
items.forEach((el) => {
|
||||||
const idx = Number(el.dataset.idx);
|
const idx = Number(el.dataset.idx);
|
||||||
const offset = idx - currentIndex;
|
// Treat the station list as circular so the coverflow loops infinitely.
|
||||||
|
// This makes the "previous" of index 0 be the last station, etc.
|
||||||
|
let offset = idx - currentIndex;
|
||||||
|
const half = Math.floor(n / 2);
|
||||||
|
if (offset > half) offset -= n;
|
||||||
|
if (offset < -half) offset += n;
|
||||||
|
|
||||||
if (Math.abs(offset) > maxVisible) {
|
if (Math.abs(offset) > maxVisible) {
|
||||||
el.style.opacity = '0';
|
el.style.opacity = '0';
|
||||||
@@ -841,6 +1107,43 @@ function loadStation(index) {
|
|||||||
if (nowArtistEl) nowArtistEl.textContent = '';
|
if (nowArtistEl) nowArtistEl.textContent = '';
|
||||||
if (nowTitleEl) nowTitleEl.textContent = '';
|
if (nowTitleEl) nowTitleEl.textContent = '';
|
||||||
|
|
||||||
|
// Update main artwork logo (best-effort). Many station logo URLs are http; try https first.
|
||||||
|
try {
|
||||||
|
if (logoTextEl && station && station.name) {
|
||||||
|
logoTextEl.textContent = String(station.name).trim();
|
||||||
|
logoTextEl.classList.add('logo-name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLogo = (station && (station.logo || (station.raw && (station.raw.logo || '')))) || '';
|
||||||
|
const rawPoster = (station && ((station.raw && station.raw.poster) || station.poster || '')) || '';
|
||||||
|
|
||||||
|
if (logoImgEl) {
|
||||||
|
// Show fallback until load completes.
|
||||||
|
logoImgEl.classList.add('hidden');
|
||||||
|
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
const candidates = uniqueNonEmpty([
|
||||||
|
toHttpsIfHttp(rawLogo),
|
||||||
|
rawLogo,
|
||||||
|
toHttpsIfHttp(rawPoster),
|
||||||
|
rawPoster,
|
||||||
|
]);
|
||||||
|
|
||||||
|
setImgWithFallback(logoImgEl, candidates, () => {
|
||||||
|
logoImgEl.classList.add('hidden');
|
||||||
|
if (logoTextEl) logoTextEl.classList.remove('hidden');
|
||||||
|
}, { dataFallbackUrls: [rawLogo, rawPoster] });
|
||||||
|
|
||||||
|
// If something loads successfully, show it.
|
||||||
|
logoImgEl.onload = () => {
|
||||||
|
logoImgEl.classList.remove('hidden');
|
||||||
|
if (logoTextEl) logoTextEl.classList.add('hidden');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
// Sync coverflow transforms (if present)
|
// Sync coverflow transforms (if present)
|
||||||
try { updateCoverflowTransforms(); } catch (e) {}
|
try { updateCoverflowTransforms(); } catch (e) {}
|
||||||
// When loading a station, ensure only this station's poller runs
|
// When loading a station, ensure only this station's poller runs
|
||||||
@@ -896,12 +1199,13 @@ async function play() {
|
|||||||
statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading
|
statusDotEl.style.backgroundColor = 'var(--text-muted)'; // Grey/Yellow while loading
|
||||||
|
|
||||||
if (currentMode === 'local') {
|
if (currentMode === 'local') {
|
||||||
audio.src = station.url;
|
|
||||||
audio.volume = volumeSlider.value / 100;
|
|
||||||
try {
|
try {
|
||||||
await audio.play();
|
const vol = volumeSlider.value / 100;
|
||||||
|
await invoke('player_set_volume', { volume: vol }).catch(() => {});
|
||||||
|
await invoke('player_play', { url: station.url });
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
updateUI();
|
updateUI();
|
||||||
|
startLocalPlayerStatePolling();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Playback failed', e);
|
console.error('Playback failed', e);
|
||||||
statusTextEl.textContent = 'Error';
|
statusTextEl.textContent = 'Error';
|
||||||
@@ -909,7 +1213,39 @@ async function play() {
|
|||||||
} else if (currentMode === 'cast' && currentCastDevice) {
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
// Cast logic
|
// Cast logic
|
||||||
try {
|
try {
|
||||||
await invoke('cast_play', { deviceName: currentCastDevice, url: station.url });
|
// UX guard: if native playback is currently decoding a different station,
|
||||||
|
// stop it explicitly before starting the cast pipeline (which would otherwise
|
||||||
|
// replace the decoder behind the scenes).
|
||||||
|
try {
|
||||||
|
const st = await invoke('player_get_state');
|
||||||
|
const nativeActive = st && (st.status === 'playing' || st.status === 'buffering') && st.url;
|
||||||
|
if (nativeActive && st.url !== station.url) {
|
||||||
|
stopLocalPlayerStatePolling();
|
||||||
|
await invoke('player_stop').catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore: best-effort guard only.
|
||||||
|
}
|
||||||
|
|
||||||
|
let castUrl = station.url;
|
||||||
|
currentCastTransport = null;
|
||||||
|
try {
|
||||||
|
const res = await invoke('cast_proxy_start', { deviceName: currentCastDevice, url: station.url });
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
castUrl = res.url || station.url;
|
||||||
|
currentCastTransport = res.mode || 'proxy';
|
||||||
|
} else {
|
||||||
|
// Backward-compat (older backend returned string)
|
||||||
|
castUrl = res || station.url;
|
||||||
|
currentCastTransport = 'proxy';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If proxy cannot start (ffmpeg missing, firewall, etc), fall back to direct station URL.
|
||||||
|
console.warn('Cast proxy start failed; falling back to direct URL', e);
|
||||||
|
currentCastTransport = 'direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke('cast_play', { deviceName: currentCastDevice, url: castUrl });
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
// Sync volume
|
// Sync volume
|
||||||
const vol = volumeSlider.value / 100;
|
const vol = volumeSlider.value / 100;
|
||||||
@@ -917,8 +1253,10 @@ async function play() {
|
|||||||
updateUI();
|
updateUI();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Cast failed', e);
|
console.error('Cast failed', e);
|
||||||
statusTextEl.textContent = 'Cast Error';
|
statusTextEl.textContent = 'Cast Error (check LAN/firewall)';
|
||||||
|
await invoke('cast_proxy_stop').catch(() => {});
|
||||||
currentMode = 'local'; // Fallback
|
currentMode = 'local'; // Fallback
|
||||||
|
currentCastTransport = null;
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -926,10 +1264,15 @@ async function play() {
|
|||||||
|
|
||||||
async function stop() {
|
async function stop() {
|
||||||
if (currentMode === 'local') {
|
if (currentMode === 'local') {
|
||||||
audio.pause();
|
stopLocalPlayerStatePolling();
|
||||||
audio.src = '';
|
try {
|
||||||
|
await invoke('player_stop');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
} else if (currentMode === 'cast' && currentCastDevice) {
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
try {
|
try {
|
||||||
|
await invoke('cast_proxy_stop').catch(() => {});
|
||||||
await invoke('cast_stop', { deviceName: currentCastDevice });
|
await invoke('cast_stop', { deviceName: currentCastDevice });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -937,6 +1280,9 @@ async function stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
|
if (currentMode !== 'cast') {
|
||||||
|
currentCastTransport = null;
|
||||||
|
}
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,24 +1310,36 @@ function updateUI() {
|
|||||||
playBtn.classList.add('playing'); // Add pulsing ring animation
|
playBtn.classList.add('playing'); // Add pulsing ring animation
|
||||||
statusTextEl.textContent = 'Playing';
|
statusTextEl.textContent = 'Playing';
|
||||||
statusDotEl.style.backgroundColor = 'var(--success)';
|
statusDotEl.style.backgroundColor = 'var(--success)';
|
||||||
stationSubtitleEl.textContent = currentMode === 'cast' ? `Casting to ${currentCastDevice}` : 'Live Stream';
|
if (currentMode === 'cast') {
|
||||||
|
const t = currentCastTransport ? ` (${currentCastTransport})` : '';
|
||||||
|
stationSubtitleEl.textContent = `Casting${t} to ${currentCastDevice}`;
|
||||||
|
} else {
|
||||||
|
stationSubtitleEl.textContent = 'Live Stream';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
iconPlay.classList.remove('hidden');
|
iconPlay.classList.remove('hidden');
|
||||||
iconStop.classList.add('hidden');
|
iconStop.classList.add('hidden');
|
||||||
playBtn.classList.remove('playing'); // Remove pulsing ring
|
playBtn.classList.remove('playing'); // Remove pulsing ring
|
||||||
statusTextEl.textContent = 'Ready';
|
statusTextEl.textContent = 'Ready';
|
||||||
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
statusDotEl.style.backgroundColor = 'var(--text-muted)';
|
||||||
stationSubtitleEl.textContent = currentMode === 'cast' ? `Connected to ${currentCastDevice}` : 'Live Stream';
|
if (currentMode === 'cast') {
|
||||||
|
const t = currentCastTransport ? ` (${currentCastTransport})` : '';
|
||||||
|
stationSubtitleEl.textContent = `Connected${t} to ${currentCastDevice}`;
|
||||||
|
} else {
|
||||||
|
stationSubtitleEl.textContent = 'Live Stream';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEngineBadge();
|
||||||
|
}
|
||||||
|
|
||||||
function handleVolumeInput() {
|
function handleVolumeInput() {
|
||||||
const val = volumeSlider.value;
|
const val = volumeSlider.value;
|
||||||
volumeValue.textContent = `${val}%`;
|
volumeValue.textContent = `${val}%`;
|
||||||
const decimals = val / 100;
|
const decimals = val / 100;
|
||||||
|
|
||||||
if (currentMode === 'local') {
|
if (currentMode === 'local') {
|
||||||
audio.volume = decimals;
|
invoke('player_set_volume', { volume: decimals }).catch(() => {});
|
||||||
} else if (currentMode === 'cast' && currentCastDevice) {
|
} else if (currentMode === 'cast' && currentCastDevice) {
|
||||||
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
|
invoke('cast_set_volume', { deviceName: currentCastDevice, volume: decimals });
|
||||||
}
|
}
|
||||||
@@ -1038,14 +1396,20 @@ async function selectCastDevice(deviceName) {
|
|||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort cleanup: stop any lingering cast transport when changing device/mode.
|
||||||
|
await invoke('cast_proxy_stop').catch(() => {});
|
||||||
|
|
||||||
if (deviceName) {
|
if (deviceName) {
|
||||||
currentMode = 'cast';
|
currentMode = 'cast';
|
||||||
currentCastDevice = deviceName;
|
currentCastDevice = deviceName;
|
||||||
castBtn.style.color = 'var(--success)';
|
castBtn.style.color = 'var(--success)';
|
||||||
|
// Transport mode gets set on play.
|
||||||
|
currentCastTransport = currentCastTransport || null;
|
||||||
} else {
|
} else {
|
||||||
currentMode = 'local';
|
currentMode = 'local';
|
||||||
currentCastDevice = null;
|
currentCastDevice = null;
|
||||||
castBtn.style.color = 'var(--text-main)';
|
castBtn.style.color = 'var(--text-main)';
|
||||||
|
currentCastTransport = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUI();
|
updateUI();
|
||||||
@@ -1055,16 +1419,60 @@ async function selectCastDevice(deviceName) {
|
|||||||
// Let's prompt user to play.
|
// Let's prompt user to play.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: stop any cast transport when leaving the window.
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
try { invoke('cast_proxy_stop'); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', init);
|
window.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
// Register Service Worker for PWA installation (non-disruptive)
|
// 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 ('serviceWorker' in navigator) {
|
||||||
|
if (runningInTauri) {
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('sw.js')
|
navigator.serviceWorker.register('sw.js')
|
||||||
.then((reg) => console.log('ServiceWorker registered:', reg.scope))
|
.then((reg) => console.log('ServiceWorker registered:', reg.scope))
|
||||||
.catch((err) => console.debug('ServiceWorker registration failed:', err));
|
.catch((err) => console.debug('ServiceWorker registration failed:', err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open overlay and show list of stations (used by menu/hamburger)
|
// Open overlay and show list of stations (used by menu/hamburger)
|
||||||
async function openStationsOverlay() {
|
async function openStationsOverlay() {
|
||||||
@@ -1127,7 +1535,9 @@ async function openStationsOverlay() {
|
|||||||
li.onclick = async () => {
|
li.onclick = async () => {
|
||||||
currentMode = 'local';
|
currentMode = 'local';
|
||||||
currentCastDevice = null;
|
currentCastDevice = null;
|
||||||
|
currentCastTransport = null;
|
||||||
castBtn.style.color = 'var(--text-main)';
|
castBtn.style.color = 'var(--text-main)';
|
||||||
|
try { await invoke('cast_proxy_stop'); } catch (_) {}
|
||||||
await setStationByIndex(idx);
|
await setStationByIndex(idx);
|
||||||
closeCastOverlay();
|
closeCastOverlay();
|
||||||
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
|
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
|
||||||
|
|||||||
143
src/styles.css
@@ -101,7 +101,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 10px; /* Slight padding from window edges if desired, or 0 */
|
padding: 8px; /* Slight padding from window edges if desired, or 0 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@@ -115,7 +115,7 @@ body {
|
|||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px;
|
padding: 11px 24px 24px;
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
-webkit-app-region: drag; /* Draggable area */
|
-webkit-app-region: drag; /* Draggable area */
|
||||||
padding: 10px 14px 8px 14px;
|
padding: 1px 14px 8px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(135deg, rgba(60,84,255,0.14), rgba(123,127,216,0.10));
|
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);
|
border: 1px solid rgba(120,130,255,0.12);
|
||||||
@@ -211,6 +211,31 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.engine-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
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); box-shadow: 0 0 10px rgba(125,255,179,0.12); }
|
||||||
|
.engine-cast { border-color: rgba(223,166,255,0.35); box-shadow: 0 0 10px rgba(223,166,255,0.12); }
|
||||||
|
.engine-html { border-color: rgba(255,255,255,0.22); }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -260,9 +285,16 @@ body {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artwork-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.artwork-container {
|
.artwork-container {
|
||||||
width: 220px;
|
width: 190px;
|
||||||
height: 220px;
|
height: 190px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 6px; /* spacing for ring */
|
padding: 6px; /* spacing for ring */
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.00));
|
background: linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.00));
|
||||||
@@ -329,6 +361,103 @@ body {
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When we don't have an icon, show the station name nicely */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork coverflow (station carousel inside artwork) */
|
||||||
|
.artwork-coverflow {
|
||||||
|
position: relative;
|
||||||
|
width: min(320px, 92vw);
|
||||||
|
height: 108px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-coverflow-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
perspective: 900px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverflow-item {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 66px;
|
||||||
|
height: 66px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.10);
|
||||||
|
box-shadow: 0 10px 26px rgba(0,0,0,0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
z-index: 1;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverflow-item.selected {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
border-color: rgba(255,255,255,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverflow-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverflow-item.fallback {
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
text-shadow: 0 2px 10px rgba(0,0,0,0.35);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverflow-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
background: rgba(30, 30, 40, 0.35);
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 3;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverflow-arrow.left { left: 10px; }
|
||||||
|
.coverflow-arrow.right { right: 10px; }
|
||||||
|
|
||||||
.station-logo-img {
|
.station-logo-img {
|
||||||
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -338,9 +467,10 @@ body {
|
|||||||
padding: 12px; /* inner spacing from rounded edges */
|
padding: 12px; /* inner spacing from rounded edges */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
|
/*box-shadow: 0 8px 20px rgba(0,0,0,0.35);*/
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
margin-left:1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logo blobs container sits behind logo but inside artwork placeholder */
|
/* Logo blobs container sits behind logo but inside artwork placeholder */
|
||||||
@@ -458,6 +588,7 @@ body {
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
margin-top: 12px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/sw.js
@@ -1,4 +1,10 @@
|
|||||||
const CACHE_NAME = 'radiocast-core-v1';
|
// NOTE: This service worker is for the web/PWA build.
|
||||||
|
// For the Tauri desktop app we aggressively unregister SWs in `src/main.js`.
|
||||||
|
//
|
||||||
|
// Bump this value whenever caching logic changes to guarantee clients don't
|
||||||
|
// keep an old UI after updates.
|
||||||
|
const CACHE_NAME = 'radiocast-core-v3';
|
||||||
|
|
||||||
const CORE_ASSETS = [
|
const CORE_ASSETS = [
|
||||||
'.',
|
'.',
|
||||||
'index.html',
|
'index.html',
|
||||||
@@ -7,20 +13,36 @@ const CORE_ASSETS = [
|
|||||||
'stations.json',
|
'stations.json',
|
||||||
'assets/favicon_io/android-chrome-192x192.png',
|
'assets/favicon_io/android-chrome-192x192.png',
|
||||||
'assets/favicon_io/android-chrome-512x512.png',
|
'assets/favicon_io/android-chrome-512x512.png',
|
||||||
'assets/favicon_io/apple-touch-icon.png'
|
'assets/favicon_io/apple-touch-icon.png',
|
||||||
|
// Optional build stamp (only present for some builds).
|
||||||
|
'build-info.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CORE_PATHS = new Set(CORE_ASSETS.map((p) => (p === '.' ? '/' : '/' + p.replace(/^\//, ''))));
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
// Activate updated SW as soon as it's installed.
|
||||||
|
self.skipWaiting();
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(CORE_ASSETS))
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
const reqs = CORE_ASSETS.map((p) => {
|
||||||
|
const url = p === '.' ? './' : p;
|
||||||
|
// Force a fresh fetch for core assets to avoid carrying forward stale UI.
|
||||||
|
return new Request(url, { cache: 'reload' });
|
||||||
|
});
|
||||||
|
return cache.addAll(reqs);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
self.clients.claim(),
|
||||||
caches.keys().then((keys) => Promise.all(
|
caches.keys().then((keys) => Promise.all(
|
||||||
keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
|
keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; })
|
||||||
))
|
)),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +50,30 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Only handle GET requests
|
// Only handle GET requests
|
||||||
if (event.request.method !== 'GET') return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Don't cache cross-origin requests (station logos, APIs, etc.).
|
||||||
|
if (url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCore = CORE_PATHS.has(url.pathname) || url.pathname === '/';
|
||||||
|
const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html');
|
||||||
|
|
||||||
|
// Network-first for navigations and core assets to prevent "old UI" issues.
|
||||||
|
if (isHtmlNavigation || isCore) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((networkResp) => {
|
||||||
|
const respClone = networkResp.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(() => {});
|
||||||
|
return networkResp;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('index.html')))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
caches.match(event.request).then((cached) => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|||||||
66
tools/copy-ffmpeg.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const tauriDir = path.join(repoRoot, 'src-tauri');
|
||||||
|
const resourcesDir = path.join(tauriDir, 'resources');
|
||||||
|
|
||||||
|
function platformBinName() {
|
||||||
|
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exists(p) {
|
||||||
|
try { return fs.existsSync(p); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(p) {
|
||||||
|
if (!exists(p)) fs.mkdirSync(p, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source lookup order:
|
||||||
|
// 1) RADIOPLAYER_FFMPEG (absolute or relative)
|
||||||
|
// 2) tools/ffmpeg/ffmpeg(.exe)
|
||||||
|
// 3) tools/ffmpeg/bin/ffmpeg(.exe)
|
||||||
|
function resolveSource() {
|
||||||
|
const env = process.env.RADIOPLAYER_FFMPEG;
|
||||||
|
if (env && String(env).trim().length > 0) {
|
||||||
|
const p = path.isAbsolute(env) ? env : path.join(repoRoot, env);
|
||||||
|
if (exists(p)) return p;
|
||||||
|
console.warn(`RADIOPLAYER_FFMPEG set but not found: ${p}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = platformBinName();
|
||||||
|
const candidates = [
|
||||||
|
path.join(repoRoot, 'tools', 'ffmpeg', name),
|
||||||
|
path.join(repoRoot, 'tools', 'ffmpeg', 'bin', name),
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidates.find(exists) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const name = platformBinName();
|
||||||
|
const src = resolveSource();
|
||||||
|
if (!src) {
|
||||||
|
console.log('FFmpeg not provided; skipping copy (set RADIOPLAYER_FFMPEG or place it under tools/ffmpeg/).');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(resourcesDir);
|
||||||
|
const dst = path.join(resourcesDir, name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(src, dst);
|
||||||
|
// Best-effort: ensure executable bit on unix-like platforms.
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
try { fs.chmodSync(dst, 0o755); } catch {}
|
||||||
|
}
|
||||||
|
console.log(`Copied FFmpeg into bundle resources: ${src} -> ${dst}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to copy FFmpeg:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
45
tools/ffmpeg/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# FFmpeg (Optional) for Native Playback
|
||||||
|
|
||||||
|
The native player uses an external **FFmpeg** binary to decode radio streams.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
- The app intentionally does **not** download or embed FFmpeg automatically.
|
||||||
|
- You provide FFmpeg yourself (license/compliance-friendly).
|
||||||
|
|
||||||
|
## How the app finds FFmpeg
|
||||||
|
|
||||||
|
At runtime it searches in this order:
|
||||||
|
|
||||||
|
1. `RADIOPLAYER_FFMPEG` environment variable (absolute or relative path)
|
||||||
|
2. Next to the application executable (Windows: `ffmpeg.exe`, macOS/Linux: `ffmpeg`)
|
||||||
|
3. Common bundle resource folders relative to the executable:
|
||||||
|
- `resources/ffmpeg(.exe)`
|
||||||
|
- `Resources/ffmpeg(.exe)`
|
||||||
|
- `../resources/ffmpeg(.exe)`
|
||||||
|
- `../Resources/ffmpeg(.exe)`
|
||||||
|
4. Your system `PATH`
|
||||||
|
|
||||||
|
## Recommended setup (Windows dev)
|
||||||
|
|
||||||
|
- Put `ffmpeg.exe` somewhere stable, then set:
|
||||||
|
|
||||||
|
`RADIOPLAYER_FFMPEG=C:\\path\\to\\ffmpeg.exe`
|
||||||
|
|
||||||
|
Or copy `ffmpeg.exe` next to the built app binary:
|
||||||
|
|
||||||
|
- `src-tauri/target/debug/ffmpeg.exe` (dev)
|
||||||
|
- `src-tauri/target/release/ffmpeg.exe` (release)
|
||||||
|
|
||||||
|
## Optional: download helper (Windows)
|
||||||
|
|
||||||
|
You can also run:
|
||||||
|
|
||||||
|
`npm run ffmpeg:download`
|
||||||
|
|
||||||
|
This downloads a prebuilt FFmpeg zip and extracts `ffmpeg.exe` into `tools/ffmpeg/bin/ffmpeg.exe`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The player will fail fast with a clear error if FFmpeg is missing.
|
||||||
|
- The project already includes a copy step (`tools/copy-ffmpeg.js`) that runs before `tauri`/`build` and places FFmpeg into `src-tauri/resources/` for bundling.
|
||||||
60
tools/sync-version.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
|
||||||
|
function readJson(p) {
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(p, obj) {
|
||||||
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCargoTomlVersion(cargoTomlPath, version) {
|
||||||
|
const input = fs.readFileSync(cargoTomlPath, 'utf8');
|
||||||
|
|
||||||
|
// Replace only the [package] version line.
|
||||||
|
const packageBlockStart = input.indexOf('[package]');
|
||||||
|
if (packageBlockStart === -1) {
|
||||||
|
throw new Error('Could not find [package] in Cargo.toml');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageBlockEnd = input.indexOf('\n[', packageBlockStart + 1);
|
||||||
|
const blockEnd = packageBlockEnd === -1 ? input.length : packageBlockEnd;
|
||||||
|
const pkgBlock = input.slice(packageBlockStart, blockEnd);
|
||||||
|
|
||||||
|
const versionRe = /^version\s*=\s*"([^"]*)"/m;
|
||||||
|
const m = pkgBlock.match(versionRe);
|
||||||
|
if (!m) {
|
||||||
|
throw new Error('Could not find version line in Cargo.toml [package] block');
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaced = pkgBlock.replace(versionRe, `version = "${version}"`);
|
||||||
|
|
||||||
|
const output = input.slice(0, packageBlockStart) + replaced + input.slice(blockEnd);
|
||||||
|
fs.writeFileSync(cargoTomlPath, output, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootPkgPath = path.join(repoRoot, 'package.json');
|
||||||
|
const tauriConfPath = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
|
||||||
|
const cargoTomlPath = path.join(repoRoot, 'src-tauri', 'Cargo.toml');
|
||||||
|
|
||||||
|
const rootPkg = readJson(rootPkgPath);
|
||||||
|
if (!rootPkg.version) throw new Error('Root package.json has no version');
|
||||||
|
|
||||||
|
const version = String(rootPkg.version);
|
||||||
|
|
||||||
|
const tauriConf = readJson(tauriConfPath);
|
||||||
|
tauriConf.version = version;
|
||||||
|
writeJson(tauriConfPath, tauriConf);
|
||||||
|
|
||||||
|
updateCargoTomlVersion(cargoTomlPath, version);
|
||||||
|
|
||||||
|
console.log(`Synced Tauri version to ${version}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('sync-version failed:', e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
54
tools/write-build-flag.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -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`.
|
|
||||||
@@ -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"}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>RadioPlayer</title>
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
<link rel="manifest" href="manifest.json">
|
|
||||||
<meta name="theme-color" content="#1f1f2e">
|
|
||||||
<link rel="apple-touch-icon" href="assets/favicon_io/apple-touch-icon.png">
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app" style="display:none"></div>
|
|
||||||
<div class="app-container">
|
|
||||||
<div class="bg-shape shape-1"></div>
|
|
||||||
<div class="bg-shape shape-2"></div>
|
|
||||||
|
|
||||||
<main class="glass-card">
|
|
||||||
<header data-tauri-drag-region>
|
|
||||||
<div class="header-top-row">
|
|
||||||
<div class="header-icons-left" aria-hidden="true">
|
|
||||||
<button id="edit-stations-btn" class="icon-btn" title="Edit Stations" aria-label="Edit Stations">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 20h9" />
|
|
||||||
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast" title="Cast">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- status moved below station info -->
|
|
||||||
|
|
||||||
<div class="header-close">
|
|
||||||
<button id="close-btn" class="icon-btn close-btn" aria-label="Close" title="Close">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-third-row">
|
|
||||||
<div class="header-icons">
|
|
||||||
<button id="edit-stations-btn" class="icon-btn" title="Edit Stations" aria-label="Edit Stations">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 20h9" />
|
|
||||||
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="cast-toggle-btn" class="icon-btn" aria-label="Cast" title="Cast">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="artwork-section">
|
|
||||||
<div class="artwork-container">
|
|
||||||
<div class="artwork-placeholder">
|
|
||||||
<!-- Gooey SVG filter for fluid blob blending -->
|
|
||||||
<svg width="0" height="0" style="position:absolute">
|
|
||||||
<defs>
|
|
||||||
<filter id="goo">
|
|
||||||
<!-- increased blur for smoother, more transparent blending -->
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
|
|
||||||
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
|
|
||||||
<feBlend in="SourceGraphic" in2="goo" />
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="logo-blobs" aria-hidden="true">
|
|
||||||
<span class="blob b1"></span>
|
|
||||||
<span class="blob b2"></span>
|
|
||||||
<span class="blob b3"></span>
|
|
||||||
<span class="blob b4"></span>
|
|
||||||
<span class="blob b5"></span>
|
|
||||||
<span class="blob b6"></span>
|
|
||||||
<span class="blob b7"></span>
|
|
||||||
<span class="blob b8"></span>
|
|
||||||
<span class="blob b9"></span>
|
|
||||||
<span class="blob b10"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
|
||||||
<span class="station-logo-text">1</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="track-info">
|
|
||||||
<h2 id="station-name"></h2>
|
|
||||||
<div id="now-playing" class="now-playing hidden" aria-live="polite">
|
|
||||||
<div id="now-artist" class="now-artist" aria-hidden="false"></div>
|
|
||||||
<div id="now-title" class="now-title" aria-hidden="false"></div>
|
|
||||||
</div>
|
|
||||||
<p id="station-subtitle"></p>
|
|
||||||
<div id="status-indicator" class="status-indicator-wrap" aria-hidden="true">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
<span id="status-text"></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Visual Progress Bar (Live) -->
|
|
||||||
<div class="progress-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill"></div>
|
|
||||||
<div class="progress-handle"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="controls-section">
|
|
||||||
<button id="prev-btn" class="control-btn secondary" aria-label="Previous Station">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="play-btn" class="control-btn primary" aria-label="Play">
|
|
||||||
<div class="icon-container">
|
|
||||||
<!-- Play Icon -->
|
|
||||||
<svg id="icon-play" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
<!-- Stop/Pause Icon (Hidden by default) -->
|
|
||||||
<svg id="icon-stop" class="hidden" width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 6h12v12H6z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="next-btn" class="control-btn secondary" aria-label="Next Station">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="volume-section">
|
|
||||||
<button id="mute-btn" class="icon-btn small">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
|
||||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="slider-container">
|
|
||||||
<input type="range" id="volume-slider" min="0" max="100" value="50">
|
|
||||||
</div>
|
|
||||||
<span id="volume-value">50%</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Hidden Cast Overlay (Beautified) -->
|
|
||||||
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
|
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
|
|
||||||
<h2 id="deviceTitle">Choose</h2>
|
|
||||||
|
|
||||||
<ul id="device-list" class="device-list">
|
|
||||||
<!-- Render device items here -->
|
|
||||||
<li class="device">
|
|
||||||
<div class="device-main">Scanning...</div>
|
|
||||||
<div class="device-sub">Searching for speakers</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stations Editor Overlay -->
|
|
||||||
<div id="editor-overlay" class="overlay hidden" aria-hidden="true">
|
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="editorTitle">
|
|
||||||
<h2 id="editorTitle">Edit Stations</h2>
|
|
||||||
|
|
||||||
<ul id="editor-list" class="device-list"></ul>
|
|
||||||
|
|
||||||
<form id="add-station-form">
|
|
||||||
<div style="margin-bottom:8px;">
|
|
||||||
<input id="us_title" placeholder="Title" required style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:8px;">
|
|
||||||
<input id="us_url" placeholder="Stream URL" required style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:8px;">
|
|
||||||
<input id="us_logo" placeholder="Logo URL (optional)" style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:12px;">
|
|
||||||
<input id="us_www" placeholder="Website (optional)" style="width:100%;padding:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:transparent;color:inherit">
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="us_id">
|
|
||||||
<input type="hidden" id="us_index">
|
|
||||||
<div style="display:flex;gap:8px;">
|
|
||||||
<button id="us_save_btn" class="btn cancel" type="submit" style="flex:1">Save</button>
|
|
||||||
<button id="editor-close-btn" class="btn" type="button" style="flex:0;background:#6b6bff">Close</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "RadioPlayer",
|
|
||||||
"short_name": "Radio",
|
|
||||||
"description": "RadioPlayer — stream radio stations from the web",
|
|
||||||
"start_url": ".",
|
|
||||||
"scope": ".",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#1f1f2e",
|
|
||||||
"theme_color": "#1f1f2e",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "assets/favicon_io/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "assets/favicon_io/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
942
webapp/package-lock.json
generated
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// RadioCast webapp entry (web-only)
|
|
||||||
// Removed Tauri-specific shims so this file runs in a plain browser.
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const app = document.getElementById('app');
|
|
||||||
if (!app) {
|
|
||||||
console.warn('No #app element found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.innerHTML = `
|
|
||||||
<main style="font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; padding: 2rem;">
|
|
||||||
<h1>RadioCast (Web)</h1>
|
|
||||||
<p>Running as a plain web application (no Tauri).</p>
|
|
||||||
<div id="status">Status: Idle</div>
|
|
||||||
</main>
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log('RadioCast webapp started (web mode)');
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
48
webapp/sw.js
@@ -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' });
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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, '..')]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||