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

9.9 KiB
Raw 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. 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)
  • 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

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