fix
This commit is contained in:
316
TECHNICAL_DOCUMENTATION.md
Normal file
316
TECHNICAL_DOCUMENTATION.md
Normal file
@@ -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<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 `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_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": "<ip>", "url": "<streamUrl>" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `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<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](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.
|
||||||
@@ -96,8 +96,12 @@
|
|||||||
<span class="blob b10"></span>
|
<span class="blob b10"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
|
<!-- Coverflow-style station carousel inside the artwork (drag or use arrows) -->
|
||||||
<span class="station-logo-text">1</span>
|
<div id="artwork-coverflow" class="artwork-coverflow" aria-label="Stations">
|
||||||
|
<button id="artwork-prev" class="coverflow-arrow left" aria-label="Previous station">‹</button>
|
||||||
|
<div id="artwork-coverflow-stage" class="artwork-coverflow-stage" role="list" aria-label="Station icons"></div>
|
||||||
|
<button id="artwork-next" class="coverflow-arrow right" aria-label="Next station">›</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
242
src/main.js
242
src/main.js
@@ -28,8 +28,9 @@ const castBtn = document.getElementById('cast-toggle-btn');
|
|||||||
const castOverlay = document.getElementById('cast-overlay');
|
const castOverlay = document.getElementById('cast-overlay');
|
||||||
const closeOverlayBtn = document.getElementById('close-overlay');
|
const closeOverlayBtn = document.getElementById('close-overlay');
|
||||||
const deviceListEl = document.getElementById('device-list');
|
const deviceListEl = document.getElementById('device-list');
|
||||||
const logoTextEl = document.querySelector('.station-logo-text');
|
const coverflowStageEl = document.getElementById('artwork-coverflow-stage');
|
||||||
const logoImgEl = document.getElementById('station-logo-img');
|
const coverflowPrevBtn = document.getElementById('artwork-prev');
|
||||||
|
const coverflowNextBtn = document.getElementById('artwork-next');
|
||||||
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
const artworkPlaceholder = document.querySelector('.artwork-placeholder');
|
||||||
// Global error handlers to avoid silent white screen and show errors in UI
|
// Global error handlers to avoid silent white screen and show errors in UI
|
||||||
window.addEventListener('error', (ev) => {
|
window.addEventListener('error', (ev) => {
|
||||||
@@ -170,6 +171,7 @@ async function loadStations() {
|
|||||||
|
|
||||||
console.debug('loadStations: loading station index', currentIndex);
|
console.debug('loadStations: loading station index', currentIndex);
|
||||||
loadStation(currentIndex);
|
loadStation(currentIndex);
|
||||||
|
renderCoverflow();
|
||||||
// start polling for currentSong endpoints (if any)
|
// start polling for currentSong endpoints (if any)
|
||||||
startCurrentSongPollers();
|
startCurrentSongPollers();
|
||||||
}
|
}
|
||||||
@@ -178,6 +180,179 @@ async function loadStations() {
|
|||||||
statusTextEl.textContent = 'Error loading stations';
|
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 ---
|
// --- Current Song Polling ---
|
||||||
@@ -628,8 +803,6 @@ function ensureArtworkPointerFallback() {
|
|||||||
|
|
||||||
// Quick inline style fallback (helps when CSS is overridden)
|
// Quick inline style fallback (helps when CSS is overridden)
|
||||||
try { ap.style.cursor = 'pointer'; } catch (e) {}
|
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;
|
let active = false;
|
||||||
const onMove = (ev) => {
|
const onMove = (ev) => {
|
||||||
@@ -668,34 +841,8 @@ function loadStation(index) {
|
|||||||
if (nowArtistEl) nowArtistEl.textContent = '';
|
if (nowArtistEl) nowArtistEl.textContent = '';
|
||||||
if (nowTitleEl) nowTitleEl.textContent = '';
|
if (nowTitleEl) nowTitleEl.textContent = '';
|
||||||
|
|
||||||
// Update Logo Text (First letter or number)
|
// Sync coverflow transforms (if present)
|
||||||
// Simple heuristic: if name has a number, use it, else first letter
|
try { updateCoverflowTransforms(); } catch (e) {}
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
// When loading a station, ensure only this station's poller runs
|
// When loading a station, ensure only this station's poller runs
|
||||||
try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); }
|
try { startCurrentSongPollers(); } catch (e) { console.debug('startCurrentSongPollers failed in loadStation', e); }
|
||||||
}
|
}
|
||||||
@@ -798,33 +945,15 @@ async function playNext() {
|
|||||||
|
|
||||||
// If playing, stop first? Or seamless?
|
// If playing, stop first? Or seamless?
|
||||||
// For radio, seamless switch requires stop then play new URL
|
// For radio, seamless switch requires stop then play new URL
|
||||||
const wasPlaying = isPlaying;
|
const nextIndex = (currentIndex + 1) % stations.length;
|
||||||
|
await setStationByIndex(nextIndex);
|
||||||
if (wasPlaying) await stop();
|
|
||||||
|
|
||||||
currentIndex = (currentIndex + 1) % stations.length;
|
|
||||||
loadStation(currentIndex);
|
|
||||||
|
|
||||||
// persist selection
|
|
||||||
saveLastStationId(stations[currentIndex].id);
|
|
||||||
|
|
||||||
if (wasPlaying) await play();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playPrev() {
|
async function playPrev() {
|
||||||
if (stations.length === 0) return;
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
const wasPlaying = isPlaying;
|
const prevIndex = (currentIndex - 1 + stations.length) % stations.length;
|
||||||
|
await setStationByIndex(prevIndex);
|
||||||
if (wasPlaying) await stop();
|
|
||||||
|
|
||||||
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
|
|
||||||
loadStation(currentIndex);
|
|
||||||
|
|
||||||
// persist selection
|
|
||||||
saveLastStationId(stations[currentIndex].id);
|
|
||||||
|
|
||||||
if (wasPlaying) await play();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
@@ -999,10 +1128,7 @@ async function openStationsOverlay() {
|
|||||||
currentMode = 'local';
|
currentMode = 'local';
|
||||||
currentCastDevice = null;
|
currentCastDevice = null;
|
||||||
castBtn.style.color = 'var(--text-main)';
|
castBtn.style.color = 'var(--text-main)';
|
||||||
currentIndex = idx;
|
await setStationByIndex(idx);
|
||||||
// Remember this selection
|
|
||||||
saveLastStationId(stations[idx].id);
|
|
||||||
loadStation(currentIndex);
|
|
||||||
closeCastOverlay();
|
closeCastOverlay();
|
||||||
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
|
try { await play(); } catch (e) { console.error('Failed to play station from grid', e); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -399,15 +399,7 @@ body {
|
|||||||
.artwork-placeholder:hover,
|
.artwork-placeholder:hover,
|
||||||
.station-logo-img,
|
.station-logo-img,
|
||||||
.station-logo-text {
|
.station-logo-text {
|
||||||
cursor: pointer !important;
|
cursor: pointer;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track Info */
|
/* Track Info */
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ if (!fs.existsSync(iconPath)) {
|
|||||||
|
|
||||||
console.log('Patching EXE icon with rcedit...');
|
console.log('Patching EXE icon with rcedit...');
|
||||||
|
|
||||||
// Prefer local installed binary (node_modules/.bin) to avoid relying on npx.
|
// Prefer local installed binary to avoid relying on npx.
|
||||||
// On Windows, npm typically creates a .cmd shim, which Node can execute.
|
// 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 binDir = path.join(repoRoot, 'node_modules', '.bin');
|
||||||
|
const packageBinDir = path.join(repoRoot, 'node_modules', 'rcedit', 'bin');
|
||||||
const localCandidates = process.platform === 'win32'
|
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.cmd'),
|
||||||
path.join(binDir, 'rcedit.exe'),
|
path.join(binDir, 'rcedit.exe'),
|
||||||
path.join(binDir, 'rcedit'),
|
path.join(binDir, 'rcedit'),
|
||||||
@@ -37,8 +42,7 @@ if (localBin) {
|
|||||||
cmd = localBin;
|
cmd = localBin;
|
||||||
args = [exePath, '--set-icon', iconPath];
|
args = [exePath, '--set-icon', iconPath];
|
||||||
} else {
|
} else {
|
||||||
// Fallback to npx. Use the platform-specific shim on Windows so Node spawn finds it.
|
// Last resort fallback to npx.
|
||||||
// PowerShell-only shims like npx.ps1 won't work with Node spawn; prefer npx.cmd.
|
|
||||||
cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||||
args = ['rcedit', exePath, '--set-icon', iconPath];
|
args = ['rcedit', exePath, '--set-icon', iconPath];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user