- Add tools/sync-version.js script to read root package.json version and update src-tauri/tauri.conf.json and src-tauri/Cargo.toml. - Update only the [package] version line in Cargo.toml to preserve formatting. - Include JSON read/write helpers and basic error handling/reporting.
11 KiB
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/main.js, src/styles.css
- Tauri host (Rust): Command layer + device discovery in src-tauri/src/lib.rs
- Native audio engine (Rust): FFmpeg decode + CPAL output in src-tauri/src/player.rs
- Cast sidecar (Node executable): Google Cast control via
castv2-clientin sidecar/index.js - Packaging utilities:
- Sidecar binary copy/rename step: tools/copy-binaries.js
- Windows EXE icon patch: tools/post-build-rcedit.js
- Optional FFmpeg bundling helper: tools/copy-ffmpeg.js (see tools/ffmpeg/README.md)
Data flow:
- UI actions call JS functions in
main.js. - JS calls Tauri commands via
window.__TAURI__.core.invoke()(for both local playback and casting). - In Local mode, Rust spawns FFmpeg and plays decoded PCM via CPAL.
- In Cast mode, the Rust backend discovers Cast devices via mDNS and stores
{ deviceName -> ip }. - 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)
- Rust toolchain (via rustup)
- Platform build tools (Windows: Visual Studio C++ Build Tools)
- Tauri prerequisites (WebView2 runtime on Windows)
Dev
From repo root:
npm installnpm run dev
This runs tauri dev (see package.json).
Production build (Windows MSI/NSIS, etc.)
From repo root:
npm run build
What it does (see package.json):
node tools/copy-binaries.js— ensures the expected bundled binary name exists.tauri build— builds the Rust host and generates platform bundles.node tools/post-build-rcedit.js— patches the Windows EXE icon using the locally installedrceditbinary.
Artifacts typically land under:
src-tauri/target/release/bundle/
Building the sidecar
The sidecar is built separately using pkg (see sidecar/package.json):
cd sidecarnpm installnpm 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:
- build.frontendDist:
../src- The desktop app serves the static files in
src/.
- The desktop app serves the static files in
- window:
width: 360,height: 720,resizable: falsedecorations: 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:
core:defaultcore:window:allow-close(allows JS to call window close)opener:defaultshell:default(required for spawning the sidecar)
Rust backend (Tauri commands)
All commands are in 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
fnTXT record if present, otherwisefullname. - First IPv4 address is preferred.
- New devices are inserted into
known_devicesand logged.
Commands
Native player commands (local playback)
Local playback is handled by the Rust engine in 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
s16lePCM and feeds a ring buffer consumed by a CPAL output stream. - Reports
buffering→playingbased 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>
- 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→ipfromknown_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:
{ "command": "play", "args": { "ip": "<ip>", "url": "<streamUrl>" } }
cast_stop(device_name: String) -> Result<(), String>
- If the sidecar process exists, writes:
{ "command": "stop", "args": {} }
cast_set_volume(device_name: String, volume: f32) -> Result<(), String>
- If the sidecar process exists, writes:
{ "command": "volume", "args": { "level": 0.0 } }
Notes:
volumeis 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
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_ALLOWEDlaunch, retries once after stopping sessions (best-effort).
Frontend behavior
Station data model
Stations are loaded from src/stations.json and normalized in src/main.js into:
{ 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 === falseor 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 ornullisPlaying: boolean
Local mode
- Uses backend invokes:
player_play,player_stop,player_set_volume. - The UI polls
player_get_stateto reflectbuffering/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 usesraw.lastSongs. - Remote URLs are fetched via the Tauri backend
fetch_urlto 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 #cast-overlay is reused for two different overlays:
- Cast device picker (
openCastOverlay()) - Station grid chooser (
openStationsOverlay())
The content is switched by:
- Toggling the
stations-gridclass on#device-list - Replacing
#device-listcontents dynamically
UI controls (button-by-button)
All UI IDs below are in src/index.html and are wired in src/main.js.
Window / header
#close-btn- Calls
getCurrentWindow().close()(requirescore:window:allow-close).
- Calls
#cast-toggle-btn- Opens the Cast overlay and lists discovered devices (
invoke('list_cast_devices')).
- Opens the Cast overlay and lists discovered devices (
#edit-stations-btn- Opens the Stations Editor overlay (user stations stored in
localStorage.userStations).
- Opens the Stations Editor overlay (user stations stored in
Note:
#cast-toggle-btnand#edit-stations-btnappear twice in the HTML header. Duplicate IDs are invalid HTML and only the first element returned bygetElementById()will be wired.
Coverflow (station carousel inside artwork)
#artwork-prev- Selects previous station via
setStationByIndex().
- Selects previous station via
#artwork-next- Selects next station via
setStationByIndex().
- Selects next station via
#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').
- Local mode:
- Toggles play/stop (
#prev-btn- Previous station (
playPrev()→setStationByIndex()).
- Previous station (
#next-btn- Next station (
playNext()→setStationByIndex()).
- Next station (
Volume
#volume-slider- Local:
invoke('player_set_volume'). - Cast:
invoke('cast_set_volume'). - Persists
localStorage.volume.
- Local:
#mute-btn- Present in the UI but currently not wired to a handler in
main.js.
- Present in the UI but currently not wired to a handler in
Cast overlay
#close-overlay- Closes the overlay (
closeCastOverlay()).
- Closes the overlay (
Stations editor overlay
#editor-close-btn- Closes the editor overlay.
#add-station-formsubmit- Adds/updates a station in
localStorage.userStations. - Triggers a full station reload (
loadStations()).
- Adds/updates a station in
Service worker / PWA pieces
- Service worker file: src/sw.js
- Caches core app assets for offline-ish behavior.
- Web manifest: 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-...toRadioPlayer-...(see tools/copy-binaries.js); ensure the bundled binary name matches whatshell.sidecar("radiocast-sidecar")expects for your target.