From f9b9ce099402d069843b0c54dda0f0fbe20a545f Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 11 Jan 2026 09:53:28 +0100 Subject: [PATCH] fix --- TECHNICAL_DOCUMENTATION.md | 316 +++++++++++++++++++++++++++++++++++++ src/index.html | 8 +- src/main.js | 242 +++++++++++++++++++++------- src/styles.css | 10 +- tools/post-build-rcedit.js | 12 +- 5 files changed, 515 insertions(+), 73 deletions(-) create mode 100644 TECHNICAL_DOCUMENTATION.md diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md new file mode 100644 index 0000000..4dede14 --- /dev/null +++ b/TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,316 @@ +# 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` + - 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 + +#### `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 `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. diff --git a/src/index.html b/src/index.html index 9b67b04..d4580c6 100644 --- a/src/index.html +++ b/src/index.html @@ -96,8 +96,12 @@ - - 1 + +
+ +
+ +
diff --git a/src/main.js b/src/main.js index a53d9a5..76e41ab 100644 --- a/src/main.js +++ b/src/main.js @@ -28,8 +28,9 @@ const castBtn = document.getElementById('cast-toggle-btn'); const castOverlay = document.getElementById('cast-overlay'); const closeOverlayBtn = document.getElementById('close-overlay'); const deviceListEl = document.getElementById('device-list'); -const logoTextEl = document.querySelector('.station-logo-text'); -const logoImgEl = document.getElementById('station-logo-img'); +const coverflowStageEl = document.getElementById('artwork-coverflow-stage'); +const coverflowPrevBtn = document.getElementById('artwork-prev'); +const coverflowNextBtn = document.getElementById('artwork-next'); const artworkPlaceholder = document.querySelector('.artwork-placeholder'); // Global error handlers to avoid silent white screen and show errors in UI window.addEventListener('error', (ev) => { @@ -170,6 +171,7 @@ async function loadStations() { console.debug('loadStations: loading station index', currentIndex); loadStation(currentIndex); + renderCoverflow(); // start polling for currentSong endpoints (if any) startCurrentSongPollers(); } @@ -178,6 +180,179 @@ async function loadStations() { statusTextEl.textContent = 'Error loading stations'; } } + +// --- Coverflow UI (3D-ish station cards like your reference image) --- +let coverflowPointerId = null; +let coverflowStartX = 0; +let coverflowLastX = 0; +let coverflowAccum = 0; +let coverflowMoved = false; +let coverflowWheelLock = false; + +function renderCoverflow() { + try { + if (!coverflowStageEl) return; + coverflowStageEl.innerHTML = ''; + + stations.forEach((s, idx) => { + const item = document.createElement('div'); + item.className = 'coverflow-item'; + item.dataset.idx = String(idx); + + const logoUrl = s.logo || (s.raw && (s.raw.logo || s.raw.poster)) || ''; + if (logoUrl) { + const img = document.createElement('img'); + img.alt = `${s.name} logo`; + img.src = logoUrl; + img.addEventListener('error', () => { + item.innerHTML = ''; + item.classList.add('fallback'); + item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?'; + }); + item.appendChild(img); + } else { + item.classList.add('fallback'); + item.textContent = s.name ? s.name.charAt(0).toUpperCase() : '?'; + } + + // Click a card: if it's not selected, select it. + // Double-click the selected card to open the full station grid. + item.addEventListener('click', async (ev) => { + if (coverflowMoved) return; + const idxClicked = Number(item.dataset.idx); + if (idxClicked !== currentIndex) { + await setStationByIndex(idxClicked); + } + }); + item.addEventListener('dblclick', (ev) => { + const idxClicked = Number(item.dataset.idx); + if (idxClicked === currentIndex) openStationsOverlay(); + }); + + coverflowStageEl.appendChild(item); + }); + + wireCoverflowInteractions(); + updateCoverflowTransforms(); + } catch (e) { + console.debug('renderCoverflow failed', e); + } +} + +function wireCoverflowInteractions() { + try { + const host = document.getElementById('artwork-coverflow'); + if (!host) return; + + // Buttons + if (coverflowPrevBtn) coverflowPrevBtn.onclick = () => setStationByIndex((currentIndex - 1 + stations.length) % stations.length); + if (coverflowNextBtn) coverflowNextBtn.onclick = () => setStationByIndex((currentIndex + 1) % stations.length); + + // Pointer drag (mouse/touch) + host.onpointerdown = (ev) => { + if (!stations || stations.length <= 1) return; + coverflowPointerId = ev.pointerId; + coverflowStartX = ev.clientX; + coverflowLastX = ev.clientX; + coverflowAccum = 0; + coverflowMoved = false; + try { host.setPointerCapture(ev.pointerId); } catch (e) {} + }; + host.onpointermove = (ev) => { + if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; + const dx = ev.clientX - coverflowLastX; + coverflowLastX = ev.clientX; + if (Math.abs(ev.clientX - coverflowStartX) > 6) coverflowMoved = true; + + // Accumulate movement; change station when threshold passed. + coverflowAccum += dx; + const threshold = 36; + if (coverflowAccum >= threshold) { + coverflowAccum = 0; + setStationByIndex((currentIndex - 1 + stations.length) % stations.length); + } else if (coverflowAccum <= -threshold) { + coverflowAccum = 0; + setStationByIndex((currentIndex + 1) % stations.length); + } + }; + host.onpointerup = (ev) => { + if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; + coverflowPointerId = null; + // reset moved flag after click would have fired + setTimeout(() => { coverflowMoved = false; }, 0); + try { host.releasePointerCapture(ev.pointerId); } catch (e) {} + }; + host.onpointercancel = (ev) => { + coverflowPointerId = null; + coverflowMoved = false; + }; + + // Wheel: next/prev with debounce + host.onwheel = (ev) => { + if (!stations || stations.length <= 1) return; + if (coverflowWheelLock) return; + const delta = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; + if (Math.abs(delta) < 6) return; + ev.preventDefault(); + coverflowWheelLock = true; + if (delta > 0) setStationByIndex((currentIndex + 1) % stations.length); + else setStationByIndex((currentIndex - 1 + stations.length) % stations.length); + setTimeout(() => { coverflowWheelLock = false; }, 160); + }; + } catch (e) { + console.debug('wireCoverflowInteractions failed', e); + } +} + +function updateCoverflowTransforms() { + try { + if (!coverflowStageEl) return; + const items = coverflowStageEl.querySelectorAll('.coverflow-item'); + const maxVisible = 3; + items.forEach((el) => { + const idx = Number(el.dataset.idx); + const offset = idx - currentIndex; + + if (Math.abs(offset) > maxVisible) { + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + el.style.transform = 'translate(-50%, -50%) scale(0.6)'; + return; + } + + const abs = Math.abs(offset); + const dir = offset === 0 ? 0 : (offset > 0 ? 1 : -1); + + const translateX = dir * (abs * 78); + const translateZ = -abs * 70; + const rotateY = dir * (-28 * abs); + const scale = 1 - abs * 0.12; + const opacity = 1 - abs * 0.18; + const zIndex = 100 - abs; + + el.style.opacity = String(opacity); + el.style.zIndex = String(zIndex); + el.style.pointerEvents = 'auto'; + el.style.transform = `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) scale(${scale})`; + + if (offset === 0) el.classList.add('selected'); + else el.classList.remove('selected'); + }); + } catch (e) { + console.debug('updateCoverflowTransforms failed', e); + } +} + +async function setStationByIndex(idx) { + if (idx < 0 || idx >= stations.length) return; + const wasPlaying = isPlaying; + if (wasPlaying) await stop(); + currentIndex = idx; + saveLastStationId(stations[currentIndex].id); + loadStation(currentIndex); + updateCoverflowTransforms(); + if (wasPlaying) await play(); +} // --- Current Song Polling --- @@ -628,8 +803,6 @@ function ensureArtworkPointerFallback() { // Quick inline style fallback (helps when CSS is overridden) try { ap.style.cursor = 'pointer'; } catch (e) {} - try { if (logoImgEl) logoImgEl.style.cursor = 'pointer'; } catch (e) {} - try { if (logoTextEl) logoTextEl.style.cursor = 'pointer'; } catch (e) {} let active = false; const onMove = (ev) => { @@ -668,34 +841,8 @@ function loadStation(index) { if (nowArtistEl) nowArtistEl.textContent = ''; if (nowTitleEl) nowTitleEl.textContent = ''; - // Update Logo Text (First letter or number) - // Simple heuristic: if name has a number, use it, else first letter - // If station has a logo URL, show the image; otherwise show the text fallback - if (station.logo && station.logo.length > 0) { - // Verify the logo exists before showing it - checkImageExists(station.logo).then((exists) => { - if (exists) { - logoImgEl.src = station.logo; - logoImgEl.classList.remove('hidden'); - logoTextEl.classList.add('hidden'); - } else { - logoImgEl.src = ''; - logoImgEl.classList.add('hidden'); - logoTextEl.classList.remove('hidden'); - } - }); - } else { - // Fallback to single-letter/logo text - logoImgEl.src = ''; - logoImgEl.classList.add('hidden'); - const numberMatch = station.name.match(/\d+/); - if (numberMatch) { - logoTextEl.textContent = numberMatch[0]; - } else { - logoTextEl.textContent = station.name.charAt(0).toUpperCase(); - } - logoTextEl.classList.remove('hidden'); - } + // Sync coverflow transforms (if present) + try { updateCoverflowTransforms(); } catch (e) {} // When loading a station, ensure only this station's poller runs try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); } } @@ -798,33 +945,15 @@ async function playNext() { // If playing, stop first? Or seamless? // For radio, seamless switch requires stop then play new URL - const wasPlaying = isPlaying; - - if (wasPlaying) await stop(); - - currentIndex = (currentIndex + 1) % stations.length; - loadStation(currentIndex); - - // persist selection - saveLastStationId(stations[currentIndex].id); - - if (wasPlaying) await play(); + const nextIndex = (currentIndex + 1) % stations.length; + await setStationByIndex(nextIndex); } async function playPrev() { if (stations.length === 0) return; - const wasPlaying = isPlaying; - - if (wasPlaying) await stop(); - - currentIndex = (currentIndex - 1 + stations.length) % stations.length; - loadStation(currentIndex); - - // persist selection - saveLastStationId(stations[currentIndex].id); - - if (wasPlaying) await play(); + const prevIndex = (currentIndex - 1 + stations.length) % stations.length; + await setStationByIndex(prevIndex); } function updateUI() { @@ -999,10 +1128,7 @@ async function openStationsOverlay() { currentMode = 'local'; currentCastDevice = null; castBtn.style.color = 'var(--text-main)'; - currentIndex = idx; - // Remember this selection - saveLastStationId(stations[idx].id); - loadStation(currentIndex); + await setStationByIndex(idx); closeCastOverlay(); try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } }; diff --git a/src/styles.css b/src/styles.css index 719257b..e29c590 100644 --- a/src/styles.css +++ b/src/styles.css @@ -399,15 +399,7 @@ body { .artwork-placeholder:hover, .station-logo-img, .station-logo-text { - cursor: pointer !important; - pointer-events: auto; -} - -/* Subtle hover affordance to make clickability clearer */ -.artwork-placeholder:hover .station-logo-img, -.artwork-placeholder:hover .station-logo-text { - transform: scale(1.03); - transition: transform 160ms ease; + cursor: pointer; } /* Track Info */ diff --git a/tools/post-build-rcedit.js b/tools/post-build-rcedit.js index d3a291f..57cb043 100644 --- a/tools/post-build-rcedit.js +++ b/tools/post-build-rcedit.js @@ -19,11 +19,16 @@ if (!fs.existsSync(iconPath)) { console.log('Patching EXE icon with rcedit...'); -// Prefer local installed binary (node_modules/.bin) to avoid relying on npx. -// On Windows, npm typically creates a .cmd shim, which Node can execute. +// Prefer local installed binary to avoid relying on npx. +// Note: the `rcedit` npm package places the binary at node_modules/rcedit/bin/rcedit.exe +// and does not always create a node_modules/.bin shim. const binDir = path.join(repoRoot, 'node_modules', '.bin'); +const packageBinDir = path.join(repoRoot, 'node_modules', 'rcedit', 'bin'); const localCandidates = process.platform === 'win32' ? [ + // Preferred: direct binary shipped by the package + path.join(packageBinDir, 'rcedit.exe'), + // Fallbacks: npm/yarn shim locations (if present) path.join(binDir, 'rcedit.cmd'), path.join(binDir, 'rcedit.exe'), path.join(binDir, 'rcedit'), @@ -37,8 +42,7 @@ if (localBin) { cmd = localBin; args = [exePath, '--set-icon', iconPath]; } else { - // Fallback to npx. Use the platform-specific shim on Windows so Node spawn finds it. - // PowerShell-only shims like npx.ps1 won't work with Node spawn; prefer npx.cmd. + // Last resort fallback to npx. cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'; args = ['rcedit', exePath, '--set-icon', iconPath]; }