10 Commits

Author SHA1 Message Date
ff9209062e Merge tag 'v0.1' into develop
Initial public preview of RadioPlayer: a lightweight Custom Google Cast Receiver + desktop sidecar for playing a LIVE radio stream.

Highlights

Custom CAF Receiver UI (HTML/CSS/JS) with branded artwork and playback status
Plays LIVE stream: https://live.radio1.si/Radio1MB (audio/mpeg, streamType: LIVE)
Controls: Play / Stop / Volume via Cast sender
Works on Nest Audio, Nest Hub/Hub Max, Chromecast, and speaker groups/stereo pairs (subject to sender capabilities)
Sidecar improvements: robust launch flow (handles NOT_ALLOWED by stopping existing sessions and retrying)
Included files

index.html
receiver.js (CAF Receiver initialization + LOAD interceptor for LIVE metadata)
styles.css (dark/purple UI)
logo.svg
index.js (launch retry + metadata fixes)
Important notes

Receiver must be hosted over HTTPS for Cast
Use Default Media Receiver for local testing; register a Custom Receiver App ID for production
Sidecar uses castv2-client (not official Google sender SDK) — group behavior may vary
No Node.js on receiver; no persistent storage guaranteed
How to test (quick)

Serve the receiver folder over HTTPS (e.g. mkcert + npx http-server).
From sender, LOAD:
contentId: https://live.radio1.si/Radio1MB
contentType: audio/mpeg
streamType: LIVE
Known limitations

UI is intentionally lightweight; some CSS effects may be limited on older devices
Sender-side group handling may need the official sender SDK for full reliability
If you want, I can draft the GitHub release body or a short changelog entry formatted for the Releases page.
2025-12-31 08:57:51 +01:00
52aa5e4914 Merge branch 'release/v0.1' 2025-12-31 08:57:45 +01:00
849f55ca75 updated readme 2025-12-31 08:56:37 +01:00
bd387cce69 small fixes 2025-12-31 08:51:00 +01:00
2d459e46d3 Merge branch 'feature/castv2-client' into develop 2025-12-31 07:33:23 +01:00
a6ca7dcdca visually updated 2025-12-31 07:33:09 +01:00
eec1cff25f beautify 2025-12-30 19:09:50 +01:00
b2f1b48d06 fixed cast 2025-12-30 18:38:25 +01:00
30ebf5bc5a visual fix 2025-12-30 15:47:48 +01:00
fe06fd9763 Initial commit 2025-12-30 15:13:02 +01:00
88 changed files with 2602 additions and 248 deletions

View File

@@ -1,4 +1,4 @@
# RadioCast
# RadioPlayer
A lightweight, cross-platform radio player built with Tauri and Vanilla JavaScript. Features local playback and Google Cast integration.
@@ -15,7 +15,7 @@ Before you begin, ensure you have the following installed on your machine:
1. **Clone the repository**:
```bash
git clone <repository-url>
cd RadioCast
cd RadioPlayer
```
2. **Install dependencies**:
@@ -106,3 +106,40 @@ To change the default window size, edit `src-tauri/tauri.conf.json`:
## License
[Add License Information Here]
## Release v0.1
Initial public preview (v0.1) — a minimal, working RadioPlayer experience:
- Custom CAF Receiver UI (HTML/CSS/JS) in `receiver/` with branded artwork and playback status.
- Plays LIVE stream: `https://live.radio1.si/Radio1MB` (contentType: `audio/mpeg`, streamType: `LIVE`).
- Desktop sidecar (`sidecar/index.js`) launches the Default Media Receiver and sends LOAD commands; launch flow now retries if the device reports `NOT_ALLOWED` by stopping existing sessions first.
Included receiver files:
- `receiver/index.html`
- `receiver/receiver.js` (CAF Receiver initialization + LOAD interceptor for LIVE metadata)
- `receiver/styles.css`
- `receiver/assets/logo.svg`
Quick testing notes
- The receiver must be served over HTTPS for Cast devices to load it. For quick local testing you can use `mkcert` + a static HTTPS server:
```bash
# create local certs
mkcert -install
mkcert localhost
# serve the receiver folder over HTTPS
npx http-server receiver -p 8443 -S -C localhost.pem -K localhost-key.pem
```
- Use the Default Media Receiver App ID while developing, or register a Custom Receiver App in the Cast Developer Console and point its URL to your hosted `index.html` for production.
Sidecar / troubleshoot
- If a Cast launch fails with `NOT_ALLOWED`, the sidecar will now attempt to stop any existing sessions on the device and retry the launch (best-effort). Check sidecar logs for `Launch NOT_ALLOWED` and subsequent retry attempts.
- Note: the sidecar uses `castv2-client` (not the official Google sender SDK). Group/stereo behavior may vary across device types — for full sender capabilities consider adding an official sender implementation.

BIN
app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

10
package-lock.json generated
View File

@@ -8,7 +8,8 @@
"name": "radio-tauri",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
"@tauri-apps/cli": "^2",
"rcedit": "^1.1.2"
}
},
"node_modules/@tauri-apps/cli": {
@@ -227,6 +228,13 @@
"engines": {
"node": ">= 10"
}
},
"node_modules/rcedit": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rcedit/-/rcedit-1.1.2.tgz",
"integrity": "sha512-z2ypB4gbINhI6wVe0JJMmdpmOpmNc4g90sE6/6JSuch5kYnjfz9CxvVPqqhShgR6GIkmtW3W2UlfiXhWljA0Fw==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -5,10 +5,11 @@
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri"
"build": "node tools/copy-binaries.js && tauri build && node tools/post-build-rcedit.js",
"tauri": "node tools/copy-binaries.js && tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
"@tauri-apps/cli": "^2",
"rcedit": "^1.1.2"
}
}

15
receiver/assets/logo.svg Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#7b7fd8"/>
<stop offset="1" stop-color="#b57cf2"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" rx="24" fill="url(#g)" />
<g fill="white" transform="translate(32,32)">
<circle cx="48" cy="48" r="28" fill="rgba(255,255,255,0.15)" />
<path d="M24 48c6-10 16-16 24-16v8c-6 0-14 4-18 12s-2 12 0 12 6-2 10-6c4-4 10-6 14-6v8c-6 0-14 4-18 12s-2 12 0 12" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" />
<text x="96" y="98" font-family="sans-serif" font-size="18" fill="white" opacity="0.95">Radio</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 815 B

27
receiver/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Radio Player</title>
<!-- Google Cast Receiver SDK -->
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="app">
<h1>Radio Player</h1>
<p id="status">Ready</p>
<div id="artwork">
<img src="assets/logo.svg" alt="Radio Player" />
</div>
<p id="station">Radio 1 Live Stream</p>
</div>
<script src="receiver.js"></script>
</body>
</html>

73
receiver/receiver.js Normal file
View File

@@ -0,0 +1,73 @@
/* Receiver for "Radio Player" using CAF Receiver SDK */
(function () {
const STREAM_URL = 'https://live.radio1.si/Radio1MB';
function $(id) { return document.getElementById(id); }
document.addEventListener('DOMContentLoaded', () => {
const context = cast.framework.CastReceiverContext.getInstance();
const playerManager = context.getPlayerManager();
const statusEl = $('status');
const stationEl = $('station');
// Intercept LOAD to enforce correct metadata for LIVE audio
playerManager.setMessageInterceptor(
cast.framework.messages.MessageType.LOAD,
(request) => {
if (!request || !request.media) return request;
request.media.contentId = request.media.contentId || STREAM_URL;
request.media.contentType = 'audio/mpeg';
request.media.streamType = cast.framework.messages.StreamType.LIVE;
request.media.metadata = request.media.metadata || {};
request.media.metadata.title = request.media.metadata.title || 'Radio 1';
request.media.metadata.images = request.media.metadata.images || [{ url: 'assets/logo.svg' }];
return request;
}
);
// Update UI on player state changes
playerManager.addEventListener(
cast.framework.events.EventType.PLAYER_STATE_CHANGED,
() => {
const state = playerManager.getPlayerState();
switch (state) {
case cast.framework.messages.PlayerState.PLAYING:
statusEl.textContent = 'Playing';
break;
case cast.framework.messages.PlayerState.PAUSED:
statusEl.textContent = 'Paused';
break;
case cast.framework.messages.PlayerState.IDLE:
statusEl.textContent = 'Stopped';
break;
default:
statusEl.textContent = state;
}
}
);
// When a new media is loaded, reflect metadata (station name, artwork)
playerManager.addEventListener(cast.framework.events.EventType.LOAD, (event) => {
const media = event && event.data && event.data.media;
if (media && media.metadata) {
if (media.metadata.title) stationEl.textContent = media.metadata.title;
if (media.metadata.images && media.metadata.images[0] && media.metadata.images[0].url) {
const img = document.querySelector('#artwork img');
img.src = media.metadata.images[0].url;
}
}
});
// Optional: reflect volume in title attribute
playerManager.addEventListener(cast.framework.events.EventType.VOLUME_CHANGED, (evt) => {
const level = evt && evt.data && typeof evt.data.level === 'number' ? evt.data.level : null;
if (level !== null) statusEl.title = `Volume: ${Math.round(level * 100)}%`;
});
// Start the cast receiver context
context.start({ statusText: 'Radio Player Ready' });
});
})();

58
receiver/styles.css Normal file
View File

@@ -0,0 +1,58 @@
html, body {
margin: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #7b7fd8, #b57cf2);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: white;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
}
#artwork {
width: 240px;
height: 240px;
margin: 20px 0;
border-radius: 24px;
overflow: hidden;
background: rgba(0,0,0,0.1);
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
#artwork img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
#status {
font-size: 18px;
opacity: 0.95;
margin: 6px 0 0 0;
}
#station {
font-size: 16px;
opacity: 0.85;
margin: 6px 0 0 0;
}
h1 {
font-size: 20px;
margin: 0 0 6px 0;
}
@media (max-width: 480px) {
#artwork { width: 160px; height: 160px; }
h1 { font-size: 18px; }
}

215
sidecar/index.js Normal file
View File

@@ -0,0 +1,215 @@
const { Client, DefaultMediaReceiver } = require('castv2-client');
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
terminal: false
});
let activeClient = null;
let activePlayer = null;
function isNotAllowedError(err) {
if (!err) return false;
const msg = (err.message || String(err)).toUpperCase();
return msg.includes('NOT_ALLOWED') || msg.includes('NOT ALLOWED');
}
function stopSessions(client, sessions, cb) {
if (!client || !sessions || sessions.length === 0) return cb();
const remaining = sessions.slice();
const stopNext = () => {
const session = remaining.shift();
if (!session) return cb();
client.stop(session, (err) => {
if (err) {
log(`Stop session failed (${session.appId || 'unknown app'}): ${err.message || String(err)}`);
} else {
log(`Stopped session (${session.appId || 'unknown app'})`);
}
// Continue regardless; best-effort.
stopNext();
});
};
stopNext();
}
function log(msg) {
console.log(JSON.stringify({ type: 'log', message: msg }));
}
function error(msg) {
console.error(JSON.stringify({ type: 'error', message: msg }));
}
rl.on('line', (line) => {
try {
const data = JSON.parse(line);
const { command, args } = data;
switch (command) {
case 'play':
play(args.ip, args.url);
break;
case 'stop':
stop();
break;
case 'volume':
setVolume(args.level);
break;
default:
error(`Unknown command: ${command}`);
}
} catch (e) {
error(`Failed to parse line: ${e.message}`);
}
});
function play(ip, url) {
if (activeClient) {
try { activeClient.close(); } catch (e) { }
}
activeClient = new Client();
activeClient.connect(ip, () => {
log(`Connected to ${ip}`);
// First, check if DefaultMediaReceiver is already running
activeClient.getSessions((err, sessions) => {
if (err) return error(`GetSessions error: ${err.message}`);
// Log sessions for debugging (appId/sessionId if available)
try {
const sessInfo = sessions.map(s => ({ appId: s.appId, sessionId: s.sessionId, displayName: s.displayName }));
log(`Sessions: ${JSON.stringify(sessInfo)}`);
} catch (e) {
log('Sessions: (unable to stringify)');
}
// DefaultMediaReceiver App ID is CC1AD845
const session = sessions.find(s => s.appId === 'CC1AD845');
if (session) {
log('Session already running, joining...');
activeClient.join(session, DefaultMediaReceiver, (err, player) => {
if (err) {
log('Join failed, attempting launch...');
log(`Join error: ${err && err.message ? err.message : String(err)}`);
// Join can fail if the session is stale; stop it and retry launch.
stopSessions(activeClient, [session], () => launchPlayer(url, /*didStopFirst*/ true));
} else {
activePlayer = player;
loadMedia(url);
}
});
} else {
// If another app is running, stop it first to avoid NOT_ALLOWED.
if (sessions.length > 0) {
log('Non-media session detected, stopping before launch...');
stopSessions(activeClient, sessions, () => launchPlayer(url, /*didStopFirst*/ true));
} else {
launchPlayer(url, /*didStopFirst*/ false);
}
}
});
});
activeClient.on('error', (err) => {
error(`Client error: ${err.message}`);
try { activeClient.close(); } catch (e) { }
activeClient = null;
activePlayer = null;
});
}
function launchPlayer(url, didStopFirst) {
if (!activeClient) return;
activeClient.launch(DefaultMediaReceiver, (err, player) => {
if (err) {
const details = `Launch error: ${err && err.message ? err.message : String(err)}${err && err.code ? ` (code: ${err.code})` : ''}`;
// If launch fails with NOT_ALLOWED, the device may be busy with another app/session.
// Best-effort: stop existing sessions once, then retry launch.
if (!didStopFirst && isNotAllowedError(err)) {
log('Launch NOT_ALLOWED; attempting to stop existing sessions and retry...');
activeClient.getSessions((sessErr, sessions) => {
if (sessErr) {
error(`${details} | GetSessions error: ${sessErr.message || String(sessErr)}`);
return;
}
stopSessions(activeClient, sessions, () => {
activeClient.launch(DefaultMediaReceiver, (retryErr, retryPlayer) => {
if (retryErr) {
const retryDetails = `Launch retry error: ${retryErr && retryErr.message ? retryErr.message : String(retryErr)}${retryErr && retryErr.code ? ` (code: ${retryErr.code})` : ''}`;
error(retryDetails);
try { error(`Launch retry error full: ${JSON.stringify(retryErr)}`); } catch (e) { /* ignore */ }
return;
}
activePlayer = retryPlayer;
loadMedia(url);
});
});
});
return;
}
error(details);
try { error(`Launch error full: ${JSON.stringify(err)}`); } catch (e) { /* ignore */ }
return;
}
activePlayer = player;
loadMedia(url);
});
}
function loadMedia(url) {
if (!activePlayer) return;
const media = {
contentId: url,
contentType: 'audio/mpeg',
streamType: 'LIVE',
metadata: {
metadataType: 0,
title: 'Radio 1'
}
};
activePlayer.load(media, { autoplay: true }, (err, status) => {
if (err) return error(`Load error: ${err.message}`);
log('Media loaded, playing...');
});
activePlayer.on('status', (status) => {
// Optional: track status
});
}
function stop() {
if (activePlayer) {
try { activePlayer.stop(); } catch (e) { }
log('Stopped playback');
}
if (activeClient) {
try { activeClient.close(); } catch (e) { }
activeClient = null;
activePlayer = null;
}
}
function setVolume(level) {
if (activeClient && activePlayer) {
activeClient.setVolume({ level }, (err, status) => {
if (err) return error(`Volume error: ${err.message}`);
log(`Volume set to ${level}`);
});
} else {
log('Volume command ignored: Player not initialized');
}
}
log('Sidecar initialized and waiting for commands');

190
sidecar/package-lock.json generated Normal file
View File

@@ -0,0 +1,190 @@
{
"name": "radiocast-sidecar",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "radiocast-sidecar",
"version": "1.0.0",
"dependencies": {
"castv2-client": "^1.2.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/castv2": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz",
"integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"protobufjs": "^6.8.8"
}
},
"node_modules/castv2-client": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/castv2-client/-/castv2-client-1.2.0.tgz",
"integrity": "sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==",
"license": "MIT",
"dependencies": {
"castv2": "~0.1.4",
"debug": "^2.2.0"
}
},
"node_modules/castv2/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/castv2/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "6.11.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz",
"integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/long": "^4.0.1",
"@types/node": ">=13.7.0",
"long": "^4.0.0"
},
"bin": {
"pbjs": "bin/pbjs",
"pbts": "bin/pbts"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
}
}
}

17
sidecar/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "radiocast-sidecar",
"version": "1.0.0",
"main": "index.js",
"bin": "index.js",
"dependencies": {
"castv2-client": "^1.2.0"
},
"pkg": {
"assets": [
"node_modules/castv2/lib/*.proto"
]
},
"scripts": {
"build": "pkg . --targets node18-win-x64 --output ../src-tauri/binaries/radiocast-sidecar-x86_64-pc-windows-msvc.exe"
}
}

73
src-tauri/Cargo.lock generated
View File

@@ -846,6 +846,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -2441,6 +2450,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2894,6 +2913,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-shell",
"tokio",
]
@@ -3531,12 +3551,44 @@ dependencies = [
"digest",
]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -3980,6 +4032,27 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c374b6db45f2a8a304f0273a15080d98c70cde86178855fc24653ba657a1144c"
dependencies = [
"encoding_rs",
"log",
"open",
"os_pipe",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"tokio",
]
[[package]]
name = "tauri-runtime"
version = "2.9.2"

View File

@@ -25,4 +25,5 @@ serde_json = "1"
rust_cast = "0.19.0"
mdns-sd = "0.17.1"
tokio = { version = "1.48.0", features = ["full"] }
tauri-plugin-shell = "2.3.3"

15
src-tauri/build_log.txt Normal file
View File

@@ -0,0 +1,15 @@
Compiling radio-tauri v0.1.0 (D:\Sites\Work\RadioCast\src-tauri)
error[E0599]: no method named `clone` found for struct `CommandChild` in the current scope
--> src\lib.rs:33:36
|
33 | let returned_child = child.clone();
| ^^^^^ method not found in `CommandChild`
error[E0599]: no method named `clone` found for struct `CommandChild` in the current scope
--> src\lib.rs:58:32
|
58 | let returned_child = child.clone();
| ^^^^^ method not found in `CommandChild`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `radio-tauri` (lib) due to 2 previous errors

View File

@@ -6,6 +6,7 @@
"permissions": [
"core:default",
"core:window:allow-close",
"opener:default"
"opener:default",
"shell:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,21 +1,23 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::sync::Mutex;
use std::thread;
use mdns_sd::{ServiceDaemon, ServiceEvent};
use rust_cast::channels::media::{Media, StreamType};
use rust_cast::channels::receiver::CastDeviceApp;
use rust_cast::CastDevice;
use tauri::State;
use serde_json::json;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
struct SidecarState {
child: Mutex<Option<CommandChild>>,
}
struct AppState {
known_devices: Mutex<HashMap<String, String>>,
}
#[tauri::command]
async fn list_cast_devices(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, String> {
async fn list_cast_devices(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let devices = state.known_devices.lock().unwrap();
let mut list: Vec<String> = devices.keys().cloned().collect();
list.sort();
@@ -24,7 +26,9 @@ async fn list_cast_devices(state: State<'_, Arc<AppState>>) -> Result<Vec<String
#[tauri::command]
async fn cast_play(
state: State<'_, Arc<AppState>>,
app: AppHandle,
state: State<'_, AppState>,
sidecar_state: State<'_, SidecarState>,
device_name: String,
url: String,
) -> Result<(), String> {
@@ -36,167 +40,118 @@ async fn cast_play(
.ok_or("Device not found")?
};
println!("Connecting to {} ({})", device_name, ip);
let mut lock = sidecar_state.child.lock().unwrap();
// Run connection logic
let ip_addr = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
// Connect to port 8009
let device = CastDevice::connect_without_host_verification(ip_addr.to_string(), 8009)
.map_err(|e| format!("Failed to connect: {:?}", e))?;
device
.connection
.connect("receiver-0")
.map_err(|e| format!("Failed to connect receiver: {:?}", e))?;
// Check if Default Media Receiver is already running
let app = CastDeviceApp::DefaultMediaReceiver;
let status = device
.receiver
.get_status()
.map_err(|e| format!("Failed to get status: {:?}", e))?;
// Determine if we need to launch or if we can use existing
let application = status.applications.iter().find(|a| a.app_id == "CC1AD845"); // Default Media Receiver ID
let (transport_id, session_id) = if let Some(app_instance) = application {
println!(
"App already running, joining session {}",
app_instance.session_id
);
(
app_instance.transport_id.clone(),
app_instance.session_id.clone(),
)
// Get or spawn child
let child = if let Some(ref mut child) = *lock {
child
} else {
println!("Launching app...");
let app_instance = device
.receiver
.launch_app(&app)
.map_err(|e| format!("Failed to launch app: {:?}", e))?;
(app_instance.transport_id, app_instance.session_id)
println!("Spawning new sidecar...");
let sidecar_command = app
.shell()
.sidecar("radiocast-sidecar")
.map_err(|e| e.to_string())?;
let (mut rx, child) = sidecar_command.spawn().map_err(|e| e.to_string())?;
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
println!("Sidecar: {}", String::from_utf8_lossy(&line))
}
CommandEvent::Stderr(line) => {
eprintln!("Sidecar Error: {}", String::from_utf8_lossy(&line))
}
_ => {}
}
}
});
*lock = Some(child);
lock.as_mut().unwrap()
};
device
.connection
.connect(&transport_id)
.map_err(|e| format!("Failed to connect transport: {:?}", e))?;
let play_cmd = json!({
"command": "play",
"args": { "ip": ip, "url": url }
});
// Load Media
let media = Media {
content_id: url,
stream_type: StreamType::Live, // Live stream
content_type: "audio/mp3".to_string(),
metadata: None,
duration: None,
};
device
.media
.load(&transport_id, &session_id, &media)
.map_err(|e| format!("Failed to load media: {:?}", e))?;
println!("Playing on {}", device_name);
child
.write(format!("{}\n", play_cmd.to_string()).as_bytes())
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn cast_stop(state: State<'_, Arc<AppState>>, device_name: String) -> Result<(), String> {
let ip = {
let devices = state.known_devices.lock().unwrap();
devices
.get(&device_name)
.cloned()
.ok_or("Device not found")?
};
let ip_addr = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
let device = CastDevice::connect_without_host_verification(ip_addr.to_string(), 8009)
.map_err(|e| format!("Failed to connect: {:?}", e))?;
device.connection.connect("receiver-0").unwrap();
let status = device
.receiver
.get_status()
.map_err(|e| format!("{:?}", e))?;
if let Some(app) = status.applications.first() {
let _transport_id = &app.transport_id;
// device.connection.connect(transport_id).unwrap();
device
.receiver
.stop_app(app.session_id.as_str())
.map_err(|e| format!("{:?}", e))?;
async fn cast_stop(
_app: AppHandle,
sidecar_state: State<'_, SidecarState>,
_device_name: String,
) -> Result<(), String> {
let mut lock = sidecar_state.child.lock().unwrap();
if let Some(ref mut child) = *lock {
let stop_cmd = json!({ "command": "stop", "args": {} });
child
.write(format!("{}\n", stop_cmd.to_string()).as_bytes())
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
async fn cast_set_volume(
state: State<'_, Arc<AppState>>,
device_name: String,
_app: AppHandle,
sidecar_state: State<'_, SidecarState>,
_device_name: String,
volume: f32,
) -> Result<(), String> {
let ip = {
let devices = state.known_devices.lock().unwrap();
devices
.get(&device_name)
.cloned()
.ok_or("Device not found")?
};
let ip_addr = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
let device = CastDevice::connect_without_host_verification(ip_addr.to_string(), 8009)
.map_err(|e| format!("Failed to connect: {:?}", e))?;
device.connection.connect("receiver-0").unwrap();
// Volume is on the receiver struct
let vol = rust_cast::channels::receiver::Volume {
level: Some(volume),
muted: None,
};
device
.receiver
.set_volume(vol)
.map_err(|e| format!("{:?}", e))?;
let mut lock = sidecar_state.child.lock().unwrap();
if let Some(ref mut child) = *lock {
let vol_cmd = json!({ "command": "volume", "args": { "level": volume } });
child
.write(format!("{}\n", vol_cmd.to_string()).as_bytes())
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app_state = Arc::new(AppState {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
app.manage(AppState {
known_devices: Mutex::new(HashMap::new()),
});
app.manage(SidecarState {
child: Mutex::new(None),
});
let state_clone = app_state.clone();
// Start Discovery Thread
let handle = app.handle().clone();
thread::spawn(move || {
let mdns = ServiceDaemon::new().expect("Failed to create daemon");
// Google Cast service
let receiver = mdns
.browse("_googlecast._tcp.local.")
.expect("Failed to browse");
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
// Try to get "fn" property for Friendly Name
let name = info
.get_property_val_str("fn")
.or_else(|| Some(info.get_fullname()))
.unwrap()
.to_string();
let addresses = info.get_addresses();
let ip = addresses
.iter()
.find(|ip| ip.is_ipv4())
.or_else(|| addresses.iter().next());
if let Some(ip) = info.get_addresses().iter().next() {
if let Some(ip) = ip {
let state = handle.state::<AppState>();
let mut devices = state.known_devices.lock().unwrap();
let ip_str = ip.to_string();
let mut devices = state_clone.known_devices.lock().unwrap();
if !devices.contains_key(&name) {
println!("Discovered Cast Device: {} at {}", name, ip_str);
devices.insert(name, ip_str);
@@ -207,10 +162,8 @@ pub fn run() {
}
}
});
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(app_state)
Ok(())
})
.invoke_handler(tauri::generate_handler![
list_cast_devices,
cast_play,

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "RadioPlayer",
"version": "0.1.0",
"identifier": "com.radio.player",
"identifier": "si.klevze.radioPlayer",
"build": {
"frontendDist": "../src"
},
@@ -25,6 +25,9 @@
"bundle": {
"active": true,
"targets": "all",
"externalBin": [
"binaries/RadioPlayer"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

BIN
src/assets/appIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

BIN
src/assets/favicon_io.zip Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -1,6 +1,4 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -35,7 +35,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a14 14 0 0 1 14 14h-2" />
<rect x="2" y="2" width="20" height="20" rx="2" ry="2" style="opacity:0" />
</svg>
</button>
<button id="close-btn" class="icon-btn close-btn" aria-label="Close">
@@ -51,6 +50,32 @@
<section class="artwork-section">
<div class="artwork-container">
<div class="artwork-placeholder">
<!-- Gooey SVG filter for fluid blob blending -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="goo">
<!-- increased blur for smoother, more transparent blending -->
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div class="logo-blobs" aria-hidden="true">
<span class="blob b1"></span>
<span class="blob b2"></span>
<span class="blob b3"></span>
<span class="blob b4"></span>
<span class="blob b5"></span>
<span class="blob b6"></span>
<span class="blob b7"></span>
<span class="blob b8"></span>
<span class="blob b9"></span>
<span class="blob b10"></span>
</div>
<img id="station-logo-img" class="station-logo-img hidden" alt="station logo">
<span class="station-logo-text">1</span>
</div>
</div>
@@ -109,14 +134,20 @@
<span id="volume-value">50%</span>
</section>
<!-- Hidden Cast Overlay -->
<div id="cast-overlay" class="overlay hidden">
<div class="overlay-content">
<h3>Connect to Device</h3>
<ul id="device-list">
<li>Searching...</li>
<!-- Hidden Cast Overlay (Beautified) -->
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
<h2 id="deviceTitle">Choose</h2>
<ul id="device-list" class="device-list">
<!-- Render device items here -->
<li class="device">
<div class="device-main">Scanning...</div>
<div class="device-sub">Searching for speakers</div>
</li>
</ul>
<button id="close-overlay">Cancel</button>
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
</div>
</div>

View File

@@ -26,6 +26,7 @@ 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');
// Init
async function init() {
@@ -37,7 +38,30 @@ async function init() {
async function loadStations() {
try {
const resp = await fetch('stations.json');
stations = await resp.json();
const raw = await resp.json();
// Normalize station objects so the rest of the app can rely on `name` and `url`.
stations = raw
.map((s) => {
// If already in the old format, keep as-is
if (s.name && s.url) return s;
const name = s.title || s.id || s.name || 'Unknown';
// Prefer liveAudio, fall back to liveVideo or any common fields
const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || '';
return {
id: s.id || name,
name,
url,
logo: s.logo || s.poster || '',
enabled: typeof s.enabled === 'boolean' ? s.enabled : true,
raw: s,
};
})
// Filter out disabled stations and those without a stream URL
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
if (stations.length > 0) {
currentIndex = 0;
loadStation(currentIndex);
@@ -58,6 +82,11 @@ function setupEventListeners() {
castBtn.addEventListener('click', openCastOverlay);
closeOverlayBtn.addEventListener('click', closeCastOverlay);
// Close overlay on background click
castOverlay.addEventListener('click', (e) => {
if (e.target === castOverlay) closeCastOverlay();
});
// Close button
document.getElementById('close-btn').addEventListener('click', async () => {
const appWindow = getCurrentWindow();
@@ -67,8 +96,7 @@ function setupEventListeners() {
// Menu button - explicit functionality or placeholder?
// For now just log or maybe show about
document.getElementById('menu-btn').addEventListener('click', () => {
// Future: Settings menu
console.log('Menu clicked');
openStationsOverlay();
});
// Hotkeys?
@@ -83,12 +111,23 @@ function loadStation(index) {
// 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) {
logoImgEl.src = station.logo;
logoImgEl.classList.remove('hidden');
logoTextEl.classList.add('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');
}
}
async function togglePlay() {
@@ -213,37 +252,37 @@ function handleVolumeInput() {
// Cast Logic
async function openCastOverlay() {
castOverlay.classList.remove('hidden');
deviceListEl.innerHTML = '<li>Scanning...</li>';
castOverlay.setAttribute('aria-hidden', 'false');
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Scanning...</div><div class="device-sub">Searching for speakers</div></li>';
try {
const devices = await invoke('list_cast_devices');
deviceListEl.innerHTML = '';
// Add "Stop Casting / Local" option
// Add "This Computer" option
const localLi = document.createElement('li');
localLi.textContent = 'This Computer (Local Playback)';
localLi.className = 'device' + (currentMode === 'local' ? ' selected' : '');
localLi.innerHTML = '<div class="device-main">This Computer</div><div class="device-sub">Local Playback</div>';
localLi.onclick = () => selectCastDevice(null);
deviceListEl.appendChild(localLi);
if (devices.length === 0) {
const li = document.createElement('li');
li.textContent = 'No speakers found';
deviceListEl.appendChild(li);
} else {
if (devices.length > 0) {
devices.forEach(d => {
const li = document.createElement('li');
li.textContent = d;
li.className = 'device' + (currentMode === 'cast' && currentCastDevice === d ? ' selected' : '');
li.innerHTML = `<div class="device-main">${d}</div><div class="device-sub">Google Cast Speaker</div>`;
li.onclick = () => selectCastDevice(d);
deviceListEl.appendChild(li);
});
}
} catch (e) {
deviceListEl.innerHTML = `<li>Error: ${e}</li>`;
deviceListEl.innerHTML = `<li class="device"><div class="device-main">Error</div><div class="device-sub">${e}</div></li>`;
}
}
function closeCastOverlay() {
castOverlay.classList.add('hidden');
castOverlay.setAttribute('aria-hidden', 'true');
}
async function selectCastDevice(deviceName) {
@@ -275,3 +314,42 @@ async function selectCastDevice(deviceName) {
}
window.addEventListener('DOMContentLoaded', init);
// Open overlay and show list of stations (used by menu/hamburger)
function openStationsOverlay() {
castOverlay.classList.remove('hidden');
castOverlay.setAttribute('aria-hidden', 'false');
deviceListEl.innerHTML = '<li class="device"><div class="device-main">Loading...</div><div class="device-sub">Preparing stations</div></li>';
// If stations not loaded yet, show message
if (!stations || stations.length === 0) {
deviceListEl.innerHTML = '<li class="device"><div class="device-main">No stations found</div><div class="device-sub">Check your stations.json</div></li>';
return;
}
deviceListEl.innerHTML = '';
stations.forEach((s, idx) => {
const li = document.createElement('li');
li.className = 'device' + (currentIndex === idx ? ' selected' : '');
const subtitle = (s.raw && s.raw.www) ? s.raw.www : (s.id || '');
li.innerHTML = `<div class="device-main">${s.name}</div><div class="device-sub">${subtitle}</div>`;
li.onclick = async () => {
// Always switch to local playback when selecting from stations menu
currentMode = 'local';
currentCastDevice = null;
castBtn.style.color = 'var(--text-main)';
// Select and play
currentIndex = idx;
loadStation(currentIndex);
closeCastOverlay();
try {
await play();
} catch (e) {
console.error('Failed to play station from menu', e);
}
};
deviceListEl.appendChild(li);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,9 +28,9 @@ body {
padding: 0;
height: 100vh;
width: 100vw;
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3);
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3, #7b7fd8);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
animation: gradientShift 12s ease-in-out infinite;
font-family: 'Segoe UI', system-ui, sans-serif;
color: var(--text-main);
overflow: hidden;
@@ -43,7 +43,13 @@ body {
0% {
background-position: 0% 50%;
}
25% {
background-position: 100% 50%;
}
50% {
background-position: 50% 100%;
}
75% {
background-position: 100% 50%;
}
100% {
@@ -206,12 +212,92 @@ header {
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
}
.artwork-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4ea8de, #6930c3);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
}
.station-logo-text {
font-size: 5rem;
font-weight: 800;
font-style: italic;
color: rgba(255,255,255,0.9);
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
position: relative;
z-index: 3;
}
.station-logo-img {
/* Fill the artwork placeholder while keeping aspect ratio and inner padding */
width: 100%;
height: 100%;
object-fit: contain;
display: block;
padding: 12px; /* inner spacing from rounded edges */
box-sizing: border-box;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
position: relative;
z-index: 3;
}
/* Logo blobs container sits behind logo but inside artwork placeholder */
.logo-blobs {
position: absolute;
inset: 0;
filter: url(#goo);
z-index: 1;
pointer-events: none;
}
.blob {
position: absolute;
border-radius: 50%;
/* more transparent overall */
opacity: 0.18;
/* slightly smaller blur for subtle definition */
filter: blur(6px);
}
.b1 { width: 110px; height: 110px; left: 8%; top: 20%; background: radial-gradient(circle at 30% 30%, #c77dff, #8b5cf6); animation: float1 6s ease-in-out infinite; }
.b2 { width: 85px; height: 85px; right: 6%; top: 10%; background: radial-gradient(circle at 30% 30%, #7bffd1, #7dffb3); animation: float2 5.5s ease-in-out infinite; }
.b3 { width: 95px; height: 95px; left: 20%; bottom: 12%; background: radial-gradient(circle at 20% 20%, #ffd07a, #ff6bf0); animation: float3 7s ease-in-out infinite; }
.b4 { width: 70px; height: 70px; right: 24%; bottom: 18%; background: radial-gradient(circle at 30% 30%, #6bd3ff, #4ea8de); animation: float4 6.5s ease-in-out infinite; }
.b5 { width: 50px; height: 50px; left: 46%; top: 36%; background: radial-gradient(circle at 40% 40%, #ffa6d6, #c77dff); animation: float5 8s ease-in-out infinite; }
/* Additional blobs */
.b6 { width: 75px; height: 75px; left: 12%; top: 48%; background: radial-gradient(circle at 30% 30%, #bde7ff, #6bd3ff); animation: float6 6.8s ease-in-out infinite; }
.b7 { width: 42px; height: 42px; right: 10%; top: 42%; background: radial-gradient(circle at 40% 40%, #ffd9b3, #ffd07a); animation: float7 7.2s ease-in-out infinite; }
.b8 { width: 70px; height: 70px; left: 34%; bottom: 8%; background: radial-gradient(circle at 30% 30%, #e3b6ff, #c77dff); animation: float8 6.4s ease-in-out infinite; }
.b9 { width: 36px; height: 36px; right: 34%; bottom: 6%; background: radial-gradient(circle at 30% 30%, #9ef7d3, #7bffd1); animation: float9 8.4s ease-in-out infinite; }
.b10 { width: 30px; height: 30px; left: 52%; bottom: 28%; background: radial-gradient(circle at 30% 30%, #ffd0f0, #ffa6d6); animation: float10 5.8s ease-in-out infinite; }
@keyframes float1 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(8px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float2 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float3 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(8px) translateX(-10px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float4 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float5 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-12px) translateX(4px) scale(1.07); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float6 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-8px) translateX(6px) scale(1.05); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float7 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(10px) translateX(-6px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float8 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-6px) translateX(10px) scale(1.03); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float9 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(12px) translateX(-4px) scale(1.06); } 100% { transform: translateY(0) translateX(0) scale(1); } }
@keyframes float10 { 0% { transform: translateY(0) translateX(0) scale(1); } 50% { transform: translateY(-10px) translateX(2px) scale(1.04); } 100% { transform: translateY(0) translateX(0) scale(1); } }
/* Slightly darken backdrop gradient so blobs read better */
.artwork-placeholder::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12));
z-index: 0;
}
/* Track Info */
@@ -402,20 +488,16 @@ input[type=range]::-webkit-slider-thumb {
height: 24px;
}
/* Cast Overlay */
/* Cast Overlay (Beautified as per layout2_plan.md) */
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(10px);
z-index: 10;
position: fixed;
inset: 0;
background: rgba(20, 10, 35, 0.45);
backdrop-filter: blur(14px);
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--card-radius);
justify-content: center;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
@@ -426,48 +508,99 @@ input[type=range]::-webkit-slider-thumb {
pointer-events: auto;
}
.overlay-content {
background: #2a2a2a;
padding: 24px;
border-radius: 16px;
width: 80%;
max-height: 70%;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
/* Modal */
.modal {
width: min(420px, calc(100vw - 48px));
padding: 22px;
border-radius: 22px;
background: rgba(30, 30, 40, 0.82);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 30px 80px rgba(0,0,0,0.6);
color: #fff;
animation: pop 0.22s ease;
-webkit-app-region: no-drag;
}
@keyframes pop {
from { transform: scale(0.94); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.modal h2 {
margin: 0 0 14px;
text-align: center;
font-size: 20px;
}
/* Device list */
.device-list {
list-style: none;
padding: 10px 5px;
margin: 0 0 18px;
max-height: 360px;
overflow-y: auto;
}
/* Device row */
.device {
padding: 12px 14px;
border-radius: 14px;
margin-bottom: 8px;
cursor: pointer;
background: rgba(255,255,255,0.05);
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
text-align: left;
}
.device:hover {
background: rgba(255,255,255,0.10);
transform: translateY(-1px);
}
.device .device-main {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
}
.overlay-content h3 {
margin-top: 0;
font-size: 1.1rem;
text-align: center;
.device .device-sub {
margin-top: 3px;
font-size: 12px;
opacity: 0.7;
color: var(--text-muted);
}
#device-list {
list-style: none;
padding: 0;
margin: 16px 0;
overflow-y: auto;
flex: 1;
/* Selected device */
.device.selected {
background: linear-gradient(135deg, #c77dff, #8b5cf6);
box-shadow: 0 0 18px rgba(199,125,255,0.65);
color: #111;
}
#device-list li {
padding: 10px;
border-bottom: 1px solid rgba(255,255,255,0.1);
cursor: pointer;
.device.selected .device-main,
.device.selected .device-sub {
color: #111;
}
#device-list li:hover {
background: rgba(255,255,255,0.1);
.device.selected .device-sub {
opacity: 0.85;
}
#close-overlay {
background: var(--danger);
/* Cancel button */
.btn.cancel {
width: 100%;
padding: 12px;
border-radius: 999px;
border: none;
padding: 10px;
border-radius: 8px;
color: white;
background: #d16b7d;
color: #fff;
font-size: 15px;
cursor: pointer;
transition: transform 0.15s ease, background 0.2s;
font-weight: 600;
}
.btn.cancel:hover {
transform: scale(1.02);
background: #e17c8d;
}

36
tools/copy-binaries.js Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const repoRoot = process.cwd();
const binariesDir = path.join(repoRoot, 'src-tauri', 'binaries');
// Existing filename and expected name (Windows x86_64 triple)
const existing = 'radiocast-sidecar-x86_64-pc-windows-msvc.exe';
const expected = 'RadioPlayer-x86_64-pc-windows-msvc.exe';
const src = path.join(binariesDir, existing);
const dst = path.join(binariesDir, expected);
try {
if (!fs.existsSync(binariesDir)) {
console.warn('binaries directory not found, skipping copy');
process.exit(0);
}
if (!fs.existsSync(src)) {
console.warn(`Source binary not found: ${src}. Skipping copy.`);
process.exit(0);
}
if (fs.existsSync(dst)) {
console.log(`Expected binary already present: ${dst}`);
process.exit(0);
}
fs.copyFileSync(src, dst);
console.log(`Copied ${existing} -> ${expected}`);
} catch (e) {
console.error('Failed to copy binary:', e);
process.exit(1);
}

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env node
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const repoRoot = process.cwd();
const exePath = path.join(repoRoot, 'src-tauri', 'target', 'release', 'RadioPlayer.exe');
const iconPath = path.join(repoRoot, 'src-tauri', 'icons', 'icon.ico');
if (!fs.existsSync(exePath)) {
console.warn(`RadioPlayer exe not found at ${exePath}. Skipping rcedit patch.`);
process.exit(0);
}
if (!fs.existsSync(iconPath)) {
console.warn(`Icon not found at ${iconPath}. Skipping rcedit patch.`);
process.exit(0);
}
console.log('Patching EXE icon with rcedit...');
// Prefer local installed binary (node_modules/.bin/rcedit) to avoid relying on npx in some CI/envs
const localBin = path.join(repoRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'rcedit.exe' : 'rcedit');
let cmd, args;
if (fs.existsSync(localBin)) {
cmd = localBin;
args = [exePath, '--set-icon', iconPath];
} else {
// fallback to npx
cmd = 'npx';
args = ['rcedit', exePath, '--set-icon', iconPath];
}
const res = spawnSync(cmd, args, { stdio: 'inherit' });
if (res.error) {
console.error(`Failed to run ${cmd}:`, res.error.message);
console.error('Ensure rcedit is installed (npm install --save-dev rcedit) or that npx is available.');
process.exit(1);
}
if (res.status !== 0) {
console.error(`rcedit exited with code ${res.status}`);
process.exit(res.status);
}
console.log('Icon patched successfully.');