# 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) - **Native audio engine (Rust)**: FFmpeg decode + CPAL output in [src-tauri/src/player.rs](src-tauri/src/player.rs) - **Cast sidecar (Node executable)**: Google Cast control via `castv2-client` in [sidecar/index.js](sidecar/index.js) - **Packaging utilities**: - Sidecar binary copy/rename step: [tools/copy-binaries.js](tools/copy-binaries.js) - Windows EXE icon patch: [tools/post-build-rcedit.js](tools/post-build-rcedit.js) - Optional FFmpeg bundling helper: [tools/copy-ffmpeg.js](tools/copy-ffmpeg.js) (see [tools/ffmpeg/README.md](tools/ffmpeg/README.md)) Data flow: 1. UI actions call JS functions in `main.js`. 2. JS calls Tauri commands via `window.__TAURI__.core.invoke()` (for both local playback and casting). 3. In **Local mode**, Rust spawns FFmpeg and plays decoded PCM via CPAL. 4. In **Cast mode**, the Rust backend discovers Cast devices via mDNS and stores `{ deviceName -> ip }`. 5. On `cast_play/stop/volume`, Rust spawns (or reuses) a **sidecar process**, then sends newline-delimited JSON commands to the sidecar stdin. ## Running and building ### 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` - maps **device name** → **IP string** - `SidecarState.child: Option` - 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 ### Native player commands (local playback) Local playback is handled by the Rust engine in [src-tauri/src/player.rs](src-tauri/src/player.rs). The UI controls it using these commands: #### `player_play(url: String) -> Result<(), String>` - Starts native playback of the provided stream URL. - Internally spawns FFmpeg to decode into `s16le` PCM and feeds a ring buffer consumed by a CPAL output stream. - Reports `buffering` → `playing` based on buffer fill/underrun. #### `player_stop() -> Result<(), String>` - Stops the native pipeline and updates state. #### `player_set_volume(volume: f32) -> Result<(), String>` - Sets volume in range `[0, 1]`. #### `player_get_state() -> Result` - Returns `{ status, url, volume, error }`. - Used by the UI to keep status text and play/stop button in sync. #### `list_cast_devices() -> Result, String>` - Returns the sorted list of discovered Cast device names. - 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": "", "url": "" } } ``` #### `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` - 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 backend invokes: `player_play`, `player_stop`, `player_set_volume`. - The UI polls `player_get_state` to reflect `buffering/playing/stopped/error`. #### 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: `invoke('player_play')` / `invoke('player_stop')`. - Cast mode: `invoke('cast_play')` / `invoke('cast_stop')`. - `#prev-btn` - Previous station (`playPrev()` → `setStationByIndex()`). - `#next-btn` - Next station (`playNext()` → `setStationByIndex()`). ### Volume - `#volume-slider` - Local: `invoke('player_set_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.