Compare commits
4 Commits
83c9bcf12e
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| dac2b0e8dc | |||
| 0541b0b776 | |||
| 6dd2025d3d | |||
| 7176cc8f4b |
18
cast-receiver/README.md
Normal file
18
cast-receiver/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Radio Player - Custom Cast Receiver
|
||||||
|
|
||||||
|
This folder contains a minimal Google Cast Web Receiver that displays a purple gradient background, station artwork, title and subtitle. It accepts `customData` hints sent from the sender (your app) for `backgroundImage`, `backgroundGradient` and `appName`.
|
||||||
|
|
||||||
|
Hosting requirements
|
||||||
|
- The receiver must be served over HTTPS and be publicly accessible.
|
||||||
|
- Recommended: host under GitHub Pages (`gh-pages` branch or `/docs` folder) or any static host (Netlify, Vercel, S3 + CloudFront).
|
||||||
|
|
||||||
|
Registering with Google Cast Console
|
||||||
|
1. Go to the Cast SDK Developer Console and create a new Application.
|
||||||
|
2. Choose "Custom Receiver" and provide the public HTTPS URL to `index.html` (e.g. `https://example.com/cast-receiver/index.html`).
|
||||||
|
3. Note the generated Application ID.
|
||||||
|
|
||||||
|
Sender changes
|
||||||
|
- After obtaining the Application ID, update your sender (sidecar) to launch that app ID instead of the DefaultMediaReceiver. The sidecar already supports passing `metadata.appId` when launching.
|
||||||
|
|
||||||
|
Testing locally
|
||||||
|
- You can serve this folder locally during development, but Chromecast devices require public HTTPS endpoints to use a registered app.
|
||||||
23
cast-receiver/index.html
Normal file
23
cast-receiver/index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Radio Player Receiver</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="bg" class="bg"></div>
|
||||||
|
<div id="app" class="app">
|
||||||
|
<div class="artwork"><img id="art" alt="Artwork"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div id="appName" class="app-name">Radio Player</div>
|
||||||
|
<h1 id="title">Radio Player</h1>
|
||||||
|
<h2 id="subtitle"></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
|
||||||
|
<script src="receiver.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
cast-receiver/receiver.js
Normal file
50
cast-receiver/receiver.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Minimal CAF receiver that applies customData theming and shows media metadata.
|
||||||
|
const context = cast.framework.CastReceiverContext.getInstance();
|
||||||
|
const playerManager = context.getPlayerManager();
|
||||||
|
|
||||||
|
function applyBranding(customData, metadata) {
|
||||||
|
try {
|
||||||
|
const bgEl = document.getElementById('bg');
|
||||||
|
const art = document.getElementById('art');
|
||||||
|
const title = document.getElementById('title');
|
||||||
|
const subtitle = document.getElementById('subtitle');
|
||||||
|
const appName = document.getElementById('appName');
|
||||||
|
|
||||||
|
if (customData) {
|
||||||
|
if (customData.backgroundImage) {
|
||||||
|
bgEl.style.backgroundImage = `url(${customData.backgroundImage})`;
|
||||||
|
bgEl.style.backgroundSize = 'cover';
|
||||||
|
bgEl.style.backgroundPosition = 'center';
|
||||||
|
} else if (customData.backgroundGradient) {
|
||||||
|
bgEl.style.background = customData.backgroundGradient;
|
||||||
|
}
|
||||||
|
if (customData.appName) appName.textContent = customData.appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
if (metadata.title) title.textContent = metadata.title;
|
||||||
|
const sub = metadata.subtitle || metadata.artist || '';
|
||||||
|
subtitle.textContent = sub;
|
||||||
|
if (metadata.images && metadata.images.length) {
|
||||||
|
art.src = metadata.images[0].url || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow UI errors
|
||||||
|
console.warn('Branding apply failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playerManager.setMessageInterceptor(cast.framework.messages.MessageType.LOAD, (request) => {
|
||||||
|
const media = request.media || {};
|
||||||
|
const customData = media.customData || {};
|
||||||
|
applyBranding(customData, media.metadata || {});
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
|
||||||
|
playerManager.addEventListener(cast.framework.events.EventType.MEDIA_STATUS, () => {
|
||||||
|
const media = playerManager.getMediaInformation();
|
||||||
|
if (media) applyBranding(media.customData || {}, media.metadata || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.start();
|
||||||
11
cast-receiver/styles.css
Normal file
11
cast-receiver/styles.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
:root{--primary:#6a0dad;--accent:#b36cf3}
|
||||||
|
html,body{height:100%;margin:0;font-family:Inter,system-ui,Arial,Helvetica,sans-serif}
|
||||||
|
body{background:linear-gradient(135deg,var(--primary),var(--accent));color:#fff}
|
||||||
|
.bg{position:fixed;inset:0;background-size:cover;background-position:center;filter:blur(10px) saturate(120%);opacity:0.9}
|
||||||
|
.app{position:relative;z-index:2;display:flex;align-items:center;gap:24px;padding:48px}
|
||||||
|
.artwork{width:320px;height:320px;flex:0 0 320px;background:rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;border-radius:8px;overflow:hidden}
|
||||||
|
.artwork img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.meta{display:flex;flex-direction:column}
|
||||||
|
.app-name{font-weight:600;opacity:0.9}
|
||||||
|
h1{margin:6px 0 0 0;font-size:28px}
|
||||||
|
h2{margin:6px 0 0 0;font-size:18px;opacity:0.9}
|
||||||
@@ -76,10 +76,13 @@ rl.on('line', (line) => {
|
|||||||
|
|
||||||
function play(ip, url, metadata) {
|
function play(ip, url, metadata) {
|
||||||
if (activeClient) {
|
if (activeClient) {
|
||||||
|
try { activeClient.removeAllListeners(); } catch (e) { }
|
||||||
try { activeClient.close(); } catch (e) { }
|
try { activeClient.close(); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClient = new Client();
|
activeClient = new Client();
|
||||||
|
// Increase max listeners for this client instance to avoid Node warnings
|
||||||
|
try { if (typeof activeClient.setMaxListeners === 'function') activeClient.setMaxListeners(50); } catch (e) {}
|
||||||
activeClient._playMetadata = metadata || {};
|
activeClient._playMetadata = metadata || {};
|
||||||
|
|
||||||
activeClient.connect(ip, () => {
|
activeClient.connect(ip, () => {
|
||||||
@@ -109,9 +112,12 @@ function play(ip, url, metadata) {
|
|||||||
// Join can fail if the session is stale; stop it and retry launch.
|
// Join can fail if the session is stale; stop it and retry launch.
|
||||||
stopSessions(activeClient, [session], () => launchPlayer(url, activeClient._playMetadata, /*didStopFirst*/ true));
|
stopSessions(activeClient, [session], () => launchPlayer(url, activeClient._playMetadata, /*didStopFirst*/ true));
|
||||||
} else {
|
} else {
|
||||||
activePlayer = player;
|
// Clean up previous player listeners before replacing
|
||||||
loadMedia(url, activeClient._playMetadata);
|
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
|
||||||
}
|
activePlayer = player;
|
||||||
|
try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {}
|
||||||
|
loadMedia(url, activeClient._playMetadata);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Backdrop or other non-media session present: skip stopping to avoid platform sender crash, just launch.
|
// Backdrop or other non-media session present: skip stopping to avoid platform sender crash, just launch.
|
||||||
@@ -134,7 +140,8 @@ function play(ip, url, metadata) {
|
|||||||
function launchPlayer(url, metadata, didStopFirst) {
|
function launchPlayer(url, metadata, didStopFirst) {
|
||||||
if (!activeClient) return;
|
if (!activeClient) return;
|
||||||
|
|
||||||
activeClient.launch(DefaultMediaReceiver, (err, player) => {
|
const launchApp = (metadata && metadata.appId) ? metadata.appId : DefaultMediaReceiver;
|
||||||
|
activeClient.launch(launchApp, (err, player) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
const details = `Launch error: ${err && err.message ? err.message : String(err)}${err && err.code ? ` (code: ${err.code})` : ''}`;
|
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.
|
// If launch fails with NOT_ALLOWED, the device may be busy with another app/session.
|
||||||
@@ -154,7 +161,9 @@ function launchPlayer(url, metadata, didStopFirst) {
|
|||||||
try { error(`Launch retry error full: ${JSON.stringify(retryErr)}`); } catch (e) { /* ignore */ }
|
try { error(`Launch retry error full: ${JSON.stringify(retryErr)}`); } catch (e) { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
|
||||||
activePlayer = retryPlayer;
|
activePlayer = retryPlayer;
|
||||||
|
try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {}
|
||||||
loadMedia(url, metadata);
|
loadMedia(url, metadata);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -166,7 +175,9 @@ function launchPlayer(url, metadata, didStopFirst) {
|
|||||||
try { error(`Launch error full: ${JSON.stringify(err)}`); } catch (e) { /* ignore */ }
|
try { error(`Launch error full: ${JSON.stringify(err)}`); } catch (e) { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
|
||||||
activePlayer = player;
|
activePlayer = player;
|
||||||
|
try { if (typeof activePlayer.setMaxListeners === 'function') activePlayer.setMaxListeners(50); } catch (e) {}
|
||||||
loadMedia(url, metadata);
|
loadMedia(url, metadata);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -175,18 +186,41 @@ function loadMedia(url, metadata) {
|
|||||||
if (!activePlayer) return;
|
if (!activePlayer) return;
|
||||||
|
|
||||||
const meta = metadata || {};
|
const meta = metadata || {};
|
||||||
|
// Build a richer metadata payload. Many receivers only honor specific
|
||||||
|
// fields; we set both Music metadata and generic hints via `customData`.
|
||||||
const media = {
|
const media = {
|
||||||
contentId: url,
|
contentId: url,
|
||||||
contentType: 'audio/mpeg',
|
contentType: 'audio/mpeg',
|
||||||
streamType: 'LIVE',
|
streamType: 'LIVE',
|
||||||
metadata: {
|
metadata: {
|
||||||
metadataType: 0,
|
// Use MusicTrack metadata (common on audio receivers) but include
|
||||||
title: meta.title || 'RadioPlayer',
|
// a subtitle field in case receivers surface it.
|
||||||
subtitle: meta.artist || meta.station || undefined,
|
metadataType: 3, // MusicTrackMediaMetadata
|
||||||
images: meta.image ? [{ url: meta.image }] : undefined
|
title: meta.title || 'Radio Station',
|
||||||
|
albumName: 'Radio Player',
|
||||||
|
artist: meta.artist || meta.subtitle || meta.station || '',
|
||||||
|
subtitle: meta.subtitle || '',
|
||||||
|
images: (meta.image ? [
|
||||||
|
{ url: meta.image },
|
||||||
|
// also include a large hint for receivers that prefer big artwork
|
||||||
|
{ url: meta.image, width: 1920, height: 1080 }
|
||||||
|
] : [])
|
||||||
|
},
|
||||||
|
// Many receivers ignore `customData`, but some Styled receivers will
|
||||||
|
// use it. Include background and theming hints here.
|
||||||
|
customData: {
|
||||||
|
appName: meta.appName || 'Radio Player',
|
||||||
|
backgroundImage: meta.backgroundImage || meta.image || undefined,
|
||||||
|
backgroundGradient: meta.bgGradient || '#6a0dad',
|
||||||
|
themeHint: {
|
||||||
|
primary: '#6a0dad',
|
||||||
|
accent: '#b36cf3'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ensure we don't accumulate 'status' listeners across loads
|
||||||
|
try { if (activePlayer && typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners('status'); } catch (e) {}
|
||||||
activePlayer.load(media, { autoplay: true }, (err, status) => {
|
activePlayer.load(media, { autoplay: true }, (err, status) => {
|
||||||
if (err) return error(`Load error: ${err.message}`);
|
if (err) return error(`Load error: ${err.message}`);
|
||||||
log('Media loaded, playing...');
|
log('Media loaded, playing...');
|
||||||
@@ -200,9 +234,11 @@ function loadMedia(url, metadata) {
|
|||||||
function stop() {
|
function stop() {
|
||||||
if (activePlayer) {
|
if (activePlayer) {
|
||||||
try { activePlayer.stop(); } catch (e) { }
|
try { activePlayer.stop(); } catch (e) { }
|
||||||
|
try { if (typeof activePlayer.removeAllListeners === 'function') activePlayer.removeAllListeners(); } catch (e) {}
|
||||||
log('Stopped playback');
|
log('Stopped playback');
|
||||||
}
|
}
|
||||||
if (activeClient) {
|
if (activeClient) {
|
||||||
|
try { if (typeof activeClient.removeAllListeners === 'function') activeClient.removeAllListeners(); } catch (e) {}
|
||||||
try { activeClient.close(); } catch (e) { }
|
try { activeClient.close(); } catch (e) { }
|
||||||
activeClient = null;
|
activeClient = null;
|
||||||
activePlayer = null;
|
activePlayer = null;
|
||||||
|
|||||||
@@ -575,7 +575,6 @@ impl Pipeline {
|
|||||||
let spawn = |codec: &str| -> Result<std::process::Child, String> {
|
let spawn = |codec: &str| -> Result<std::process::Child, String> {
|
||||||
command_hidden(&ffmpeg)
|
command_hidden(&ffmpeg)
|
||||||
.arg("-nostdin")
|
.arg("-nostdin")
|
||||||
.arg("-re")
|
|
||||||
.arg("-hide_banner")
|
.arg("-hide_banner")
|
||||||
.arg("-loglevel")
|
.arg("-loglevel")
|
||||||
.arg("warning")
|
.arg("warning")
|
||||||
@@ -636,7 +635,7 @@ impl Pipeline {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(256);
|
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(1024);
|
||||||
*self.cast_tx.lock().unwrap() = Some(tx);
|
*self.cast_tx.lock().unwrap() = Some(tx);
|
||||||
|
|
||||||
let writer_join = std::thread::spawn(move || {
|
let writer_join = std::thread::spawn(move || {
|
||||||
@@ -666,7 +665,7 @@ impl Pipeline {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
let mut ffmpeg_out = stdout;
|
let mut ffmpeg_out = stdout;
|
||||||
let mut buffer = vec![0u8; 8192];
|
let mut buffer = vec![0u8; 16384];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if reader_stop.load(Ordering::SeqCst) {
|
if reader_stop.load(Ordering::SeqCst) {
|
||||||
@@ -725,7 +724,7 @@ impl Pipeline {
|
|||||||
|
|
||||||
// Spawn handler for each client
|
// Spawn handler for each client
|
||||||
let stop_flag = Arc::clone(&server_stop_clone);
|
let stop_flag = Arc::clone(&server_stop_clone);
|
||||||
let (client_tx, client_rx) = mpsc::sync_channel::<Vec<u8>>(256);
|
let (client_tx, client_rx) = mpsc::sync_channel::<Vec<u8>>(1024);
|
||||||
|
|
||||||
// Subscribe this client
|
// Subscribe this client
|
||||||
clients_server.lock().unwrap().push(client_tx);
|
clients_server.lock().unwrap().push(client_tx);
|
||||||
@@ -743,11 +742,28 @@ impl Pipeline {
|
|||||||
|
|
||||||
// Send HTTP response headers
|
// Send HTTP response headers
|
||||||
let mut writer = stream;
|
let mut writer = stream;
|
||||||
let headers = b"HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nConnection: close\r\nCache-Control: no-cache\r\n\r\n";
|
let headers = b"HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nConnection: close\r\nCache-Control: no-cache\r\nAccept-Ranges: none\r\nicy-br: 128\r\n\r\n";
|
||||||
if writer.write_all(headers).is_err() {
|
if writer.write_all(headers).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-buffer before streaming to prevent initial stuttering
|
||||||
|
let mut prebuffer = Vec::with_capacity(65536);
|
||||||
|
let prebuffer_start = std::time::Instant::now();
|
||||||
|
while prebuffer.len() < 32768 && prebuffer_start.elapsed() < Duration::from_millis(500) {
|
||||||
|
match client_rx.recv_timeout(Duration::from_millis(50)) {
|
||||||
|
Ok(chunk) => prebuffer.extend_from_slice(&chunk),
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send prebuffered data
|
||||||
|
if !prebuffer.is_empty() {
|
||||||
|
if writer.write_all(&prebuffer).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stream chunks to client
|
// Stream chunks to client
|
||||||
loop {
|
loop {
|
||||||
if stop_flag.load(Ordering::SeqCst) {
|
if stop_flag.load(Ordering::SeqCst) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.1.1",
|
|
||||||
"debug": false,
|
|
||||||
"builtAt": "2026-01-13T12:34:31.351Z"
|
|
||||||
}
|
|
||||||
@@ -1268,7 +1268,11 @@ async function play() {
|
|||||||
url: castUrl,
|
url: castUrl,
|
||||||
title: station.title || 'Radio',
|
title: station.title || 'Radio',
|
||||||
artist: station.slogan || undefined,
|
artist: station.slogan || undefined,
|
||||||
image: station.logo || undefined
|
image: station.logo || undefined,
|
||||||
|
// Additional metadata hints for receivers
|
||||||
|
subtitle: station.slogan || station.name,
|
||||||
|
backgroundImage: station.background || station.logo || undefined,
|
||||||
|
bgGradient: station.bgGradient || 'linear-gradient(135deg,#5b2d91,#b36cf3)'
|
||||||
});
|
});
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
// Sync volume
|
// Sync volume
|
||||||
|
|||||||
Reference in New Issue
Block a user