diff --git a/receiver/assets/logo.svg b/receiver/assets/logo.svg new file mode 100644 index 0000000..d093b4e --- /dev/null +++ b/receiver/assets/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + Radio + + diff --git a/receiver/index.html b/receiver/index.html new file mode 100644 index 0000000..a49f4c5 --- /dev/null +++ b/receiver/index.html @@ -0,0 +1,27 @@ + + + + + + Radio Player + + + + + + + +
+

Radio Player

+

Ready

+ +
+ Radio Player +
+ +

Radio 1 – Live Stream

+
+ + + + diff --git a/receiver/receiver.js b/receiver/receiver.js new file mode 100644 index 0000000..4e82e93 --- /dev/null +++ b/receiver/receiver.js @@ -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' }); + }); +})(); diff --git a/receiver/styles.css b/receiver/styles.css new file mode 100644 index 0000000..baefcf4 --- /dev/null +++ b/receiver/styles.css @@ -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; } +} diff --git a/sidecar/index.js b/sidecar/index.js index ef431d3..e84a5f8 100644 --- a/sidecar/index.js +++ b/sidecar/index.js @@ -9,6 +9,34 @@ const rl = readline.createInterface({ 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 })); } @@ -71,14 +99,21 @@ function play(ip, url) { if (err) { log('Join failed, attempting launch...'); log(`Join error: ${err && err.message ? err.message : String(err)}`); - launchPlayer(url); + // 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 { - launchPlayer(url); + // 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); + } } }); }); @@ -91,13 +126,37 @@ function play(ip, url) { }); } -function launchPlayer(url) { +function launchPlayer(url, didStopFirst) { if (!activeClient) return; activeClient.launch(DefaultMediaReceiver, (err, player) => { if (err) { - // If launch fails with NOT_ALLOWED, it sometimes means we MUST join or something else is occupying it 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; @@ -112,8 +171,12 @@ function loadMedia(url) { const media = { contentId: url, - contentType: 'audio/mp3', - streamType: 'LIVE' + contentType: 'audio/mpeg', + streamType: 'LIVE', + metadata: { + metadataType: 0, + title: 'Radio 1' + } }; activePlayer.load(media, { autoplay: true }, (err, status) => {