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