Files
RadioPlayer/TECHNICAL_DOCUMENTATION.md
Gregor Klevze 694f335408 tools: add sync-version.js to sync package.json -> Tauri files
- 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.
2026-01-13 07:21:51 +01:00

11 KiB
Raw Permalink Blame History

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

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)
  • 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).

Production build (Windows MSI/NSIS, etc.)

From repo root:

  • npm run build

What it does (see 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):

  • 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:

  • 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:

  • 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 and registered via invoke_handler.

Shared state

  • AppState.known_devices: HashMap<String, String>
    • maps device nameIP 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

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 s16le PCM and feeds a ring buffer consumed by a CPAL output stream.
  • Reports bufferingplaying 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>

  • 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_nameip 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:
{ "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:

  • 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

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 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 === 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 #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 and are wired in 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.
  • #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
    • 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-... to RadioPlayer-... (see tools/copy-binaries.js); ensure the bundled binary name matches what shell.sidecar("radiocast-sidecar") expects for your target.