Compare commits
9 Commits
5934d24f7f
...
v0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 52aa5e4914 | |||
| 849f55ca75 | |||
| bd387cce69 | |||
| 2d459e46d3 | |||
| a6ca7dcdca | |||
| eec1cff25f | |||
| b2f1b48d06 | |||
| 30ebf5bc5a | |||
| fe06fd9763 |
41
README.md
@@ -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.
|
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**:
|
1. **Clone the repository**:
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd RadioCast
|
cd RadioPlayer
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies**:
|
2. **Install dependencies**:
|
||||||
@@ -106,3 +106,40 @@ To change the default window size, edit `src-tauri/tauri.conf.json`:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
[Add License Information Here]
|
[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
|
After Width: | Height: | Size: 290 KiB |
10
package-lock.json
generated
@@ -8,7 +8,8 @@
|
|||||||
"name": "radio-tauri",
|
"name": "radio-tauri",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2",
|
||||||
|
"rcedit": "^1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli": {
|
"node_modules/@tauri-apps/cli": {
|
||||||
@@ -227,6 +228,13 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"build": "tauri build",
|
"build": "node tools/copy-binaries.js && tauri build && node tools/post-build-rcedit.js",
|
||||||
"tauri": "tauri"
|
"tauri": "node tools/copy-binaries.js && tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2",
|
||||||
|
"rcedit": "^1.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
receiver/assets/logo.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -846,6 +846,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2441,6 +2450,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2894,6 +2913,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-shell",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3531,12 +3551,44 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
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]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -3980,6 +4032,27 @@ dependencies = [
|
|||||||
"zbus",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.9.2"
|
version = "2.9.2"
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ serde_json = "1"
|
|||||||
rust_cast = "0.19.0"
|
rust_cast = "0.19.0"
|
||||||
mdns-sd = "0.17.1"
|
mdns-sd = "0.17.1"
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
|
tauri-plugin-shell = "2.3.3"
|
||||||
|
|
||||||
|
|||||||
BIN
src-tauri/binaries/RadioPlayer-x86_64-pc-windows-msvc.exe
Normal file
BIN
src-tauri/binaries/radiocast-sidecar-x86_64-pc-windows-msvc.exe
Normal file
15
src-tauri/build_log.txt
Normal 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
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"opener:default"
|
"opener:default",
|
||||||
|
"shell:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -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>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 158 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 193 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 516 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
@@ -1,21 +1,23 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::sync::Mutex;
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
use rust_cast::channels::media::{Media, StreamType};
|
use serde_json::json;
|
||||||
use rust_cast::channels::receiver::CastDeviceApp;
|
use tauri::{AppHandle, Manager, State};
|
||||||
use rust_cast::CastDevice;
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri::State;
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
|
struct SidecarState {
|
||||||
|
child: Mutex<Option<CommandChild>>,
|
||||||
|
}
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
known_devices: Mutex<HashMap<String, String>>,
|
known_devices: Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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 devices = state.known_devices.lock().unwrap();
|
||||||
let mut list: Vec<String> = devices.keys().cloned().collect();
|
let mut list: Vec<String> = devices.keys().cloned().collect();
|
||||||
list.sort();
|
list.sort();
|
||||||
@@ -24,7 +26,9 @@ async fn list_cast_devices(state: State<'_, Arc<AppState>>) -> Result<Vec<String
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cast_play(
|
async fn cast_play(
|
||||||
state: State<'_, Arc<AppState>>,
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
sidecar_state: State<'_, SidecarState>,
|
||||||
device_name: String,
|
device_name: String,
|
||||||
url: String,
|
url: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -36,167 +40,118 @@ async fn cast_play(
|
|||||||
.ok_or("Device not found")?
|
.ok_or("Device not found")?
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Connecting to {} ({})", device_name, ip);
|
let mut lock = sidecar_state.child.lock().unwrap();
|
||||||
|
|
||||||
// Run connection logic
|
// Get or spawn child
|
||||||
let ip_addr = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
|
let child = if let Some(ref mut child) = *lock {
|
||||||
|
child
|
||||||
// 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(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
println!("Launching app...");
|
println!("Spawning new sidecar...");
|
||||||
let app_instance = device
|
let sidecar_command = app
|
||||||
.receiver
|
.shell()
|
||||||
.launch_app(&app)
|
.sidecar("radiocast-sidecar")
|
||||||
.map_err(|e| format!("Failed to launch app: {:?}", e))?;
|
.map_err(|e| e.to_string())?;
|
||||||
(app_instance.transport_id, app_instance.session_id)
|
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
|
let play_cmd = json!({
|
||||||
.connection
|
"command": "play",
|
||||||
.connect(&transport_id)
|
"args": { "ip": ip, "url": url }
|
||||||
.map_err(|e| format!("Failed to connect transport: {:?}", e))?;
|
});
|
||||||
|
|
||||||
// Load Media
|
child
|
||||||
let media = Media {
|
.write(format!("{}\n", play_cmd.to_string()).as_bytes())
|
||||||
content_id: url,
|
.map_err(|e| e.to_string())?;
|
||||||
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);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cast_stop(state: State<'_, Arc<AppState>>, device_name: String) -> Result<(), String> {
|
async fn cast_stop(
|
||||||
let ip = {
|
_app: AppHandle,
|
||||||
let devices = state.known_devices.lock().unwrap();
|
sidecar_state: State<'_, SidecarState>,
|
||||||
devices
|
_device_name: String,
|
||||||
.get(&device_name)
|
) -> Result<(), String> {
|
||||||
.cloned()
|
let mut lock = sidecar_state.child.lock().unwrap();
|
||||||
.ok_or("Device not found")?
|
if let Some(ref mut child) = *lock {
|
||||||
};
|
let stop_cmd = json!({ "command": "stop", "args": {} });
|
||||||
|
child
|
||||||
let ip_addr = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
|
.write(format!("{}\n", stop_cmd.to_string()).as_bytes())
|
||||||
let device = CastDevice::connect_without_host_verification(ip_addr.to_string(), 8009)
|
.map_err(|e| e.to_string())?;
|
||||||
.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))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cast_set_volume(
|
async fn cast_set_volume(
|
||||||
state: State<'_, Arc<AppState>>,
|
_app: AppHandle,
|
||||||
device_name: String,
|
sidecar_state: State<'_, SidecarState>,
|
||||||
|
_device_name: String,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let ip = {
|
let mut lock = sidecar_state.child.lock().unwrap();
|
||||||
let devices = state.known_devices.lock().unwrap();
|
if let Some(ref mut child) = *lock {
|
||||||
devices
|
let vol_cmd = json!({ "command": "volume", "args": { "level": volume } });
|
||||||
.get(&device_name)
|
child
|
||||||
.cloned()
|
.write(format!("{}\n", vol_cmd.to_string()).as_bytes())
|
||||||
.ok_or("Device not found")?
|
.map_err(|e| e.to_string())?;
|
||||||
};
|
}
|
||||||
|
|
||||||
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))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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()),
|
known_devices: Mutex::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
app.manage(SidecarState {
|
||||||
|
child: Mutex::new(None),
|
||||||
|
});
|
||||||
|
|
||||||
let state_clone = app_state.clone();
|
let handle = app.handle().clone();
|
||||||
|
|
||||||
// Start Discovery Thread
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mdns = ServiceDaemon::new().expect("Failed to create daemon");
|
let mdns = ServiceDaemon::new().expect("Failed to create daemon");
|
||||||
// Google Cast service
|
|
||||||
let receiver = mdns
|
let receiver = mdns
|
||||||
.browse("_googlecast._tcp.local.")
|
.browse("_googlecast._tcp.local.")
|
||||||
.expect("Failed to browse");
|
.expect("Failed to browse");
|
||||||
|
|
||||||
while let Ok(event) = receiver.recv() {
|
while let Ok(event) = receiver.recv() {
|
||||||
match event {
|
match event {
|
||||||
ServiceEvent::ServiceResolved(info) => {
|
ServiceEvent::ServiceResolved(info) => {
|
||||||
// Try to get "fn" property for Friendly Name
|
|
||||||
let name = info
|
let name = info
|
||||||
.get_property_val_str("fn")
|
.get_property_val_str("fn")
|
||||||
.or_else(|| Some(info.get_fullname()))
|
.or_else(|| Some(info.get_fullname()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.to_string();
|
.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 ip_str = ip.to_string();
|
||||||
let mut devices = state_clone.known_devices.lock().unwrap();
|
|
||||||
if !devices.contains_key(&name) {
|
if !devices.contains_key(&name) {
|
||||||
println!("Discovered Cast Device: {} at {}", name, ip_str);
|
println!("Discovered Cast Device: {} at {}", name, ip_str);
|
||||||
devices.insert(name, ip_str);
|
devices.insert(name, ip_str);
|
||||||
@@ -207,10 +162,8 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Ok(())
|
||||||
tauri::Builder::default()
|
})
|
||||||
.plugin(tauri_plugin_opener::init())
|
|
||||||
.manage(app_state)
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
list_cast_devices,
|
list_cast_devices,
|
||||||
cast_play,
|
cast_play,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "RadioPlayer",
|
"productName": "RadioPlayer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.radio.player",
|
"identifier": "si.klevze.radioPlayer",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
},
|
},
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/RadioPlayer"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
BIN
src/assets/appIcon.png
Normal file
|
After Width: | Height: | Size: 682 KiB |
BIN
src/assets/favicon_io.zip
Normal file
BIN
src/assets/favicon_io/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
src/assets/favicon_io/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
src/assets/favicon_io/app-icon.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
src/assets/favicon_io/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
src/assets/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
src/assets/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/favicon_io/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
src/assets/favicon_io/site.webmanifest
Normal 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"}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="206" height="231" viewBox="0 0 206 231">
|
||||||
<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"/>
|
<!-- Wrapper SVG that embeds the PNG app icon so existing references to tauri.svg render the PNG -->
|
||||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
<image href="appIcon.png" width="206" height="231" preserveAspectRatio="xMidYMid slice" />
|
||||||
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 289 B |
@@ -35,7 +35,6 @@
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="close-btn" class="icon-btn close-btn" aria-label="Close">
|
<button id="close-btn" class="icon-btn close-btn" aria-label="Close">
|
||||||
@@ -51,6 +50,32 @@
|
|||||||
<section class="artwork-section">
|
<section class="artwork-section">
|
||||||
<div class="artwork-container">
|
<div class="artwork-container">
|
||||||
<div class="artwork-placeholder">
|
<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>
|
<span class="station-logo-text">1</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,14 +134,20 @@
|
|||||||
<span id="volume-value">50%</span>
|
<span id="volume-value">50%</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Hidden Cast Overlay -->
|
<!-- Hidden Cast Overlay (Beautified) -->
|
||||||
<div id="cast-overlay" class="overlay hidden">
|
<div id="cast-overlay" class="overlay hidden" aria-hidden="true" data-tauri-drag-region>
|
||||||
<div class="overlay-content">
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="deviceTitle">
|
||||||
<h3>Connect to Device</h3>
|
<h2 id="deviceTitle">Choose</h2>
|
||||||
<ul id="device-list">
|
|
||||||
<li>Searching...</li>
|
<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>
|
</ul>
|
||||||
<button id="close-overlay">Cancel</button>
|
|
||||||
|
<button id="close-overlay" class="btn cancel" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
104
src/main.js
@@ -26,6 +26,7 @@ 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 logoTextEl = document.querySelector('.station-logo-text');
|
||||||
|
const logoImgEl = document.getElementById('station-logo-img');
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -37,7 +38,30 @@ async function init() {
|
|||||||
async function loadStations() {
|
async function loadStations() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('stations.json');
|
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) {
|
if (stations.length > 0) {
|
||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
loadStation(currentIndex);
|
loadStation(currentIndex);
|
||||||
@@ -58,6 +82,11 @@ function setupEventListeners() {
|
|||||||
castBtn.addEventListener('click', openCastOverlay);
|
castBtn.addEventListener('click', openCastOverlay);
|
||||||
closeOverlayBtn.addEventListener('click', closeCastOverlay);
|
closeOverlayBtn.addEventListener('click', closeCastOverlay);
|
||||||
|
|
||||||
|
// Close overlay on background click
|
||||||
|
castOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === castOverlay) closeCastOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
// Close button
|
// Close button
|
||||||
document.getElementById('close-btn').addEventListener('click', async () => {
|
document.getElementById('close-btn').addEventListener('click', async () => {
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
@@ -67,8 +96,7 @@ function setupEventListeners() {
|
|||||||
// Menu button - explicit functionality or placeholder?
|
// Menu button - explicit functionality or placeholder?
|
||||||
// For now just log or maybe show about
|
// For now just log or maybe show about
|
||||||
document.getElementById('menu-btn').addEventListener('click', () => {
|
document.getElementById('menu-btn').addEventListener('click', () => {
|
||||||
// Future: Settings menu
|
openStationsOverlay();
|
||||||
console.log('Menu clicked');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hotkeys?
|
// Hotkeys?
|
||||||
@@ -83,12 +111,23 @@ function loadStation(index) {
|
|||||||
|
|
||||||
// Update Logo Text (First letter or number)
|
// Update Logo Text (First letter or number)
|
||||||
// Simple heuristic: if name has a number, use it, else first letter
|
// 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+/);
|
const numberMatch = station.name.match(/\d+/);
|
||||||
if (numberMatch) {
|
if (numberMatch) {
|
||||||
logoTextEl.textContent = numberMatch[0];
|
logoTextEl.textContent = numberMatch[0];
|
||||||
} else {
|
} else {
|
||||||
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
logoTextEl.textContent = station.name.charAt(0).toUpperCase();
|
||||||
}
|
}
|
||||||
|
logoTextEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlay() {
|
async function togglePlay() {
|
||||||
@@ -213,37 +252,37 @@ function handleVolumeInput() {
|
|||||||
// Cast Logic
|
// Cast Logic
|
||||||
async function openCastOverlay() {
|
async function openCastOverlay() {
|
||||||
castOverlay.classList.remove('hidden');
|
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 {
|
try {
|
||||||
const devices = await invoke('list_cast_devices');
|
const devices = await invoke('list_cast_devices');
|
||||||
deviceListEl.innerHTML = '';
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
// Add "Stop Casting / Local" option
|
// Add "This Computer" option
|
||||||
const localLi = document.createElement('li');
|
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);
|
localLi.onclick = () => selectCastDevice(null);
|
||||||
deviceListEl.appendChild(localLi);
|
deviceListEl.appendChild(localLi);
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length > 0) {
|
||||||
const li = document.createElement('li');
|
|
||||||
li.textContent = 'No speakers found';
|
|
||||||
deviceListEl.appendChild(li);
|
|
||||||
} else {
|
|
||||||
devices.forEach(d => {
|
devices.forEach(d => {
|
||||||
const li = document.createElement('li');
|
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);
|
li.onclick = () => selectCastDevice(d);
|
||||||
deviceListEl.appendChild(li);
|
deviceListEl.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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() {
|
function closeCastOverlay() {
|
||||||
castOverlay.classList.add('hidden');
|
castOverlay.classList.add('hidden');
|
||||||
|
castOverlay.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCastDevice(deviceName) {
|
async function selectCastDevice(deviceName) {
|
||||||
@@ -275,3 +314,42 @@ async function selectCastDevice(deviceName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', init);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
1346
src/stations.json
219
src/styles.css
@@ -28,9 +28,9 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3);
|
background: linear-gradient(-45deg, #7b7fd8, #b57cf2, #8b5cf6, #6930c3, #7b7fd8);
|
||||||
background-size: 400% 400%;
|
background-size: 400% 400%;
|
||||||
animation: gradientShift 15s ease infinite;
|
animation: gradientShift 12s ease-in-out infinite;
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -43,7 +43,13 @@ body {
|
|||||||
0% {
|
0% {
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
}
|
}
|
||||||
|
25% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
50% {
|
50% {
|
||||||
|
background-position: 50% 100%;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
background-position: 100% 50%;
|
background-position: 100% 50%;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
@@ -206,12 +212,92 @@ header {
|
|||||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.2);
|
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 {
|
.station-logo-text {
|
||||||
font-size: 5rem;
|
font-size: 5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(255,255,255,0.9);
|
color: rgba(255,255,255,0.9);
|
||||||
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
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 */
|
/* Track Info */
|
||||||
@@ -402,20 +488,16 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cast Overlay */
|
/* Cast Overlay (Beautified as per layout2_plan.md) */
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
background: rgba(20, 10, 35, 0.45);
|
||||||
width: 100%;
|
backdrop-filter: blur(14px);
|
||||||
height: 100%;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--card-radius);
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
@@ -426,48 +508,99 @@ input[type=range]::-webkit-slider-thumb {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-content {
|
/* Modal */
|
||||||
background: #2a2a2a;
|
.modal {
|
||||||
padding: 24px;
|
width: min(420px, calc(100vw - 48px));
|
||||||
border-radius: 16px;
|
padding: 22px;
|
||||||
width: 80%;
|
border-radius: 22px;
|
||||||
max-height: 70%;
|
background: rgba(30, 30, 40, 0.82);
|
||||||
display: flex;
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
flex-direction: column;
|
box-shadow: 0 30px 80px rgba(0,0,0,0.6);
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
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);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-content h3 {
|
.device .device-sub {
|
||||||
margin-top: 0;
|
margin-top: 3px;
|
||||||
font-size: 1.1rem;
|
font-size: 12px;
|
||||||
text-align: center;
|
opacity: 0.7;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
#device-list {
|
/* Selected device */
|
||||||
list-style: none;
|
.device.selected {
|
||||||
padding: 0;
|
background: linear-gradient(135deg, #c77dff, #8b5cf6);
|
||||||
margin: 16px 0;
|
box-shadow: 0 0 18px rgba(199,125,255,0.65);
|
||||||
overflow-y: auto;
|
color: #111;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#device-list li {
|
.device.selected .device-main,
|
||||||
padding: 10px;
|
.device.selected .device-sub {
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
color: #111;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#device-list li:hover {
|
.device.selected .device-sub {
|
||||||
background: rgba(255,255,255,0.1);
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
#close-overlay {
|
/* Cancel button */
|
||||||
background: var(--danger);
|
.btn.cancel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px;
|
background: #d16b7d;
|
||||||
border-radius: 8px;
|
color: #fff;
|
||||||
color: white;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, background 0.2s;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn.cancel:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background: #e17c8d;
|
||||||
|
}
|
||||||
|
|||||||
36
tools/copy-binaries.js
Normal 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);
|
||||||
|
}
|
||||||
47
tools/post-build-rcedit.js
Normal 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.');
|
||||||