Files
RadioPlayer/TECHNICAL_DOCUMENTATION.md
2026-01-11 09:53:28 +01:00

317 lines
9.9 KiB
Markdown
Raw Blame History

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