Skip to content
4 changes: 2 additions & 2 deletions src/components/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type ClientProps = {


export default function Client(props: ClientProps) {
const [update, setUpdate] = useState(0);
const [, setUpdate] = useState(0);
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
const [open, setOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
Expand Down Expand Up @@ -82,7 +82,7 @@ export default function Client(props: ClientProps) {
function handleMuteClicked() {
console.debug("handleMuteClicked");
props.snapcontrol.setVolume(props.client.id, props.client.config.volume.percent, !props.client.config.volume.muted);
setUpdate(update + 1);
setUpdate(u => u + 1);
}

const menuitems = [];
Expand Down
15 changes: 10 additions & 5 deletions src/components/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type GroupVolumeChange = {
};

export default function Group(props: GroupProps) {
const [update, setUpdate] = useState(0);
const [, setUpdate] = useState(0);
const [settingsOpen, setSettingsOpen] = useState(false);
const [clients, setClients] = useState<GroupClient[]>([]);
const [streamId, setStreamId] = useState("");
Expand All @@ -38,6 +38,11 @@ export default function Group(props: GroupProps) {

function updateVolume() {
const clients = getClients();
if (clients.length === 0) {
// avoid dividing by zero (NaN volume) for empty/offline groups
setVolume(0);
return;
}
let volume = 0;
for (const client of clients)
volume += client.config.volume.percent;
Expand Down Expand Up @@ -100,7 +105,7 @@ export default function Group(props: GroupProps) {
newclients[idx].inGroup = inGroup;
setClients(newclients);
// dummy update, since the array was just mutated
setUpdate(update + 1);
setUpdate(u => u + 1);
}

function handleClientDelete(client: Snapcast.Client) {
Expand All @@ -110,7 +115,7 @@ export default function Group(props: GroupProps) {
newDeletedClients.push(client);
setDeletedClients(newDeletedClients);
// dummy update, since the array was just mutated
setUpdate(update + 1);
setUpdate(u => u + 1);
}

function handleClientVolumeChange(client: Snapcast.Client) {
Expand All @@ -128,14 +133,14 @@ export default function Group(props: GroupProps) {
newDeletedClients.splice(newDeletedClients.indexOf(client), 1);

setDeletedClients(newDeletedClients);
setUpdate(update + 1);
setUpdate(u => u + 1);
}

function handleMuteClicked() {
console.debug("handleMuteClicked");
props.group.muted = !props.group.muted;
props.snapcontrol.muteGroup(props.group.id, props.group.muted);
setUpdate(update + 1);
setUpdate(u => u + 1);
}

function handleVolumeChange(value: number) {
Expand Down
24 changes: 10 additions & 14 deletions src/components/SnapWeb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const darkTheme = createTheme({


export default function SnapWeb() {
const [update, setUpdate] = useState(0);
const [, setUpdate] = useState(0);
const [server, setServer] = useState(new Snapcast.Server());
const [drawerOpen, setDrawerOpen] = useState(false);
const [showOffline, setShowOffline] = useState(config.showOffline);
Expand All @@ -104,14 +104,16 @@ export default function SnapWeb() {
const audioRef = useRef(new Audio());
const snapControlRef = useRef(new SnapControl());

// json = { "groups": [{ "clients": [{ "config": { "instance": 1, "latency": 0, "name": "Küche", "volume": { "muted": false, "percent": 41 } }, "connected": true, "host": { "arch": "armv7l", "ip": "::ffff:192.168.0.252", "mac": "b8:27:eb:45:e1:ae", "name": "kueche", "os": "Raspbian GNU/Linux 11 (bullseye)" }, "id": "b8:27:eb:45:e1:ae", "lastSeen": { "sec": 1659107107, "usec": 70451 }, "snapclient": { "name": "Snapclient", "protocolVersion": 2, "version": "0.26.0" } }, { "config": { "instance": 1, "latency": 0, "name": "Wohnzimmer", "volume": { "muted": false, "percent": 81 } }, "connected": true, "host": { "arch": "armv7l", "ip": "::ffff:192.168.0.3", "mac": "dc:a6:32:3f:bd:1c", "name": "raspberrypi", "os": "Raspbian GNU/Linux 11 (bullseye)" }, "id": "dc:a6:32:3f:bd:1c", "lastSeen": { "sec": 1659107106, "usec": 967903 }, "snapclient": { "name": "Snapclient", "protocolVersion": 2, "version": "0.26.0" } }, { "config": { "instance": 1, "latency": 0, "name": "", "volume": { "muted": false, "percent": 36 } }, "connected": false, "host": { "arch": "web", "ip": "192.168.0.38", "mac": "00:00:00:00:00:00", "name": "Snapweb client", "os": "Linux x86_64" }, "id": "2cb68ccc-94bb-444a-9837-12b80cb4ef64", "lastSeen": { "sec": 1659073670, "usec": 52728 }, "snapclient": { "name": "Snapweb", "protocolVersion": 2, "version": "0.4.0" } }, { "config": { "instance": 1, "latency": 0, "name": "Arbeitszimmer", "volume": { "muted": false, "percent": 73 } }, "connected": true, "host": { "arch": "armv7l", "ip": "::ffff:192.168.0.8", "mac": "74:da:38:3e:d2:56", "name": "arbeitszimmer", "os": "Raspbian GNU/Linux 10 (buster)" }, "id": "74:da:38:3e:d2:56", "lastSeen": { "sec": 1659107106, "usec": 344276 }, "snapclient": { "name": "Snapclient", "protocolVersion": 2, "version": "0.26.0" } }, { "config": { "instance": 1, "latency": 0, "name": "", "volume": { "muted": false, "percent": 28 } }, "connected": false, "host": { "arch": "arm64-v8a", "ip": "::ffff:192.168.0.192", "mac": "00:00:00:00:00:00", "name": "Pixel 4a", "os": "Android 12" }, "id": "d91f7722-44c7-4d52-b63e-984611238b75", "lastSeen": { "sec": 1659076681, "usec": 458282 }, "snapclient": { "name": "Snapclient", "protocolVersion": 2, "version": "0.26.0" } }], "id": "e02e0600-e68c-b128-147b-58ca0a063ecf", "muted": false, "name": "", "stream_id": "default" }, { "clients": [{ "config": { "instance": 1, "latency": 0, "name": "", "volume": { "muted": false, "percent": 2 } }, "connected": false, "host": { "arch": "web", "ip": "192.168.0.10", "mac": "00:00:00:00:00:00", "name": "Snapweb client", "os": "Win32" }, "id": "6bf61c54-a88c-4b97-8447-e186a52c673d", "lastSeen": { "sec": 1658131455, "usec": 112522 }, "snapclient": { "name": "Snapweb", "protocolVersion": 2, "version": "0.4.0" } }], "id": "1d5d515b-d5c0-2831-6415-c85225c4315f", "muted": false, "name": "", "stream_id": "default" }, { "clients": [{ "config": { "instance": 1, "latency": 0, "name": "", "volume": { "muted": false, "percent": 60 } }, "connected": false, "host": { "arch": "web", "ip": "192.168.0.10", "mac": "00:00:00:00:00:00", "name": "Snapweb client", "os": "Win32" }, "id": "7ce9a092-a6d7-4508-b5d3-310fb5c73a32", "lastSeen": { "sec": 1658135991, "usec": 462267 }, "snapclient": { "name": "Snapweb", "protocolVersion": 2, "version": "0.4.0" } }], "id": "78a849df-1025-bb10-cd7e-c7751bb1642c", "muted": false, "name": "", "stream_id": "default" }, { "clients": [{ "config": { "instance": 1, "latency": 0, "name": "", "volume": { "muted": false, "percent": 54 } }, "connected": false, "host": { "arch": "web", "ip": "192.168.0.192", "mac": "00:00:00:00:00:00", "name": "Snapweb client", "os": "Linux armv8l" }, "id": "1497ade9-c94b-4528-bc2c-b61c5d26bc38", "lastSeen": { "sec": 1658734688, "usec": 964264 }, "snapclient": { "name": "Snapweb", "protocolVersion": 2, "version": "0.4.0" } }], "id": "2c4fd25e-c1a3-0123-79d0-969425e5c0c2", "muted": false, "name": "", "stream_id": "default" }, { "clients": [{ "config": { "instance": 1, "latency": 0, "name": "", "volume": { "muted": false, "percent": 100 } }, "connected": false, "host": { "arch": "web", "ip": "192.168.0.189", "mac": "00:00:00:00:00:00", "name": "Snapweb client", "os": "Linux armv8l" }, "id": "979c8e19-ed8c-4fe7-a0e2-568a069b1549", "lastSeen": { "sec": 1659103069, "usec": 235493 }, "snapclient": { "name": "Snapweb", "protocolVersion": 2, "version": "0.4.0" } }], "id": "b5d6c40c-86d1-a952-f11b-d97254b9a3ca", "muted": false, "name": "", "stream_id": "default" }], "server": { "host": { "arch": "armv7l", "ip": "", "mac": "", "name": "raspberrypi", "os": "Raspbian GNU/Linux 11 (bullseye)" }, "snapserver": { "controlProtocolVersion": 1, "name": "Snapserver", "protocolVersion": 1, "version": "0.26.0" } }, "streams": [{ "id": "default", "properties": { "canControl": true, "canGoNext": true, "canGoPrevious": true, "canPause": true, "canPlay": true, "canSeek": false, "loopStatus": "none", "metadata": { "artUrl": "http://cdn-profiles.tunein.com/s60240/images/logoq.png?t=636326", "artist": ["Radio Båstad 96.1 (Top 40 & Pop Music)"], "title": "Radio Bastad", "url": "tunein:station:s60240" }, "mute": false, "playbackStatus": "playing", "position": 0.9070000052452087, "shuffle": false, "volume": 100 }, "status": "playing", "uri": { "fragment": "", "host": "", "path": "/", "query": { "chunk_ms": "20", "codec": "flac", "controlscript": "/home/pi/meta_mopidy.py", "controlscriptparams": "--mopidy-host=192.168.0.3", "device": "hw:0,1,1", "name": "default", "sampleformat": "44100:16:2" }, "raw": "alsa:////?chunk_ms=20&codec=flac&controlscript=/home/pi/meta_mopidy.py&controlscriptparams=--mopidy-host=192.168.0.3&device=hw:0,1,1&name=default&sampleformat=44100:16:2", "scheme": "alsa" } }, { "id": "Spotify", "properties": { "canControl": true, "canGoNext": true, "canGoPrevious": true, "canPause": true, "canPlay": true, "canSeek": true, "loopStatus": "none", "metadata": { "album": "BREATHE", "artUrl": "http://i.scdn.co/image/ab67616d00001e020e6264910a1693e12310289d", "artist": ["Felix Jaehn", "VIZE", "Miss Li"], "contentCreated": "2021-10-01", "discNumber": 1, "duration": 159.96200561523438, "title": "Close Your Eyes", "trackId": "F0D74146287C4BD08E3427CE7C7D4533", "trackNumber": 2, "url": "spotify:track:7kswSnEiwuwuOQngMvpflV" }, "mute": false, "playbackStatus": "paused", "position": 0, "rate": 1, "shuffle": false, "volume": 100 }, "status": "idle", "uri": { "fragment": "", "host": "", "path": "//home/pi/Develop/librespot-java/librespot-api.sh", "query": { "chunk_ms": "20", "codec": "flac", "controlscript": "/home/pi/meta_librespot-java.py", "name": "Spotify", "sampleformat": "44100:16:2" }, "raw": "process://///home/pi/Develop/librespot-java/librespot-api.sh?chunk_ms=20&codec=flac&controlscript=/home/pi/meta_librespot-java.py&name=Spotify&sampleformat=44100:16:2", "scheme": "process" } }, { "id": "Meta", "properties": { "canControl": true, "canGoNext": true, "canGoPrevious": true, "canPause": true, "canPlay": true, "canSeek": true, "loopStatus": "none", "metadata": { "album": "BREATHE", "artUrl": "http://i.scdn.co/image/ab67616d00001e020e6264910a1693e12310289d", "artist": ["Felix Jaehn", "VIZE", "Miss Li"], "contentCreated": "2021-10-01", "discNumber": 1, "duration": 159.96200561523438, "title": "Close Your Eyes", "trackId": "F0D74146287C4BD08E3427CE7C7D4533", "trackNumber": 2, "url": "spotify:track:7kswSnEiwuwuOQngMvpflV" }, "mute": false, "playbackStatus": "paused", "position": 0, "rate": 1, "shuffle": false, "volume": 100 }, "status": "playing", "uri": { "fragment": "", "host": "", "path": "/Spotify/default", "query": { "chunk_ms": "20", "codec": "flac", "name": "Meta", "sampleformat": "44100:16:2" }, "raw": "meta:////Spotify/default?chunk_ms=20&codec=flac&name=Meta&sampleformat=44100:16:2", "scheme": "meta" } }] };
// server.fromJson(json);
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');

// Update color theme when the prefered theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _event => {
setTheme(config.theme);
});
// Update color theme when the preferred theme changes. Registered once via
// useEffect (registering in the render body leaked a listener per render).
useEffect(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setTheme(config.theme);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);

useEffect(() => {
console.debug("server updated");
Expand All @@ -130,7 +132,7 @@ export default function SnapWeb() {
function handleChange(snapserver: Snapcast.Server) {
console.debug("Update: " + server.groups.length + " => " + snapserver.groups.length);
setServer(snapserver);
setUpdate(update + 1);
setUpdate(u => u + 1);
updateMediaSession();
}

Expand Down Expand Up @@ -224,23 +226,17 @@ export default function SnapWeb() {
mediaSession.setActionHandler('seekbackward', properties.canSeek ?
(event: MediaSessionActionDetails) => {
const offset: number = (event.seekOffset || defaultSkipTime) * -1;
if (properties.position !== undefined)
Math.max(properties.position! + offset, 0);
snapControlRef.current.control(streamId, 'seek', { 'offset': offset })
} : null);

mediaSession.setActionHandler('seekforward', properties.canSeek ? (event: MediaSessionActionDetails) => {
const offset: number = event.seekOffset || defaultSkipTime;
if ((metadata?.duration !== undefined) && (properties.position !== undefined))
Math.min(properties.position! + offset, metadata.duration!);
snapControlRef.current.control(streamId, 'seek', { 'offset': offset })
} : null);

try {
mediaSession.setActionHandler('seekto', properties.canSeek ? (event: MediaSessionActionDetails) => {
const position: number = event.seekTime || 0;
if (metadata?.duration !== undefined)
Math.min(position, metadata.duration!);
snapControlRef.current.control(streamId, 'setPosition', { 'position': position })
} : null);
} catch (error) {
Expand Down
23 changes: 18 additions & 5 deletions src/snapstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ function getChromeVersion(): number | null {
}

function uuidv4(): string {
// crypto.randomUUID is only available in secure contexts (https/localhost);
// fall back to a Math.random-based UUID when it's missing or throws.
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
try {
return crypto.randomUUID();
} catch {
/* fall through to the manual implementation */
}
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : ((r & 0x3) | 0x8);
return v.toString(16);
Expand Down Expand Up @@ -176,10 +185,11 @@ class JsonMessage extends BaseMessage {
serialize(): ArrayBuffer {
const buffer = super.serialize();
const view = new DataView(buffer);
const jsonStr = JSON.stringify(this.json);
view.setUint32(26, jsonStr.length, true);
const encoder = new TextEncoder();
const encoded = encoder.encode(jsonStr);
const encoded = encoder.encode(JSON.stringify(this.json));
// size must be the UTF-8 byte length (matches getSize()), not the
// UTF-16 string length, otherwise non-ASCII payloads are truncated.
view.setUint32(26, encoded.length, true);
for (let i = 0; i < encoded.length; ++i)
view.setUint8(30 + i, encoded[i]);
return buffer;
Expand Down Expand Up @@ -459,7 +469,10 @@ class AudioStream {
while ((read < readFrames) && this.chunk) {
const pcmChunk = this.chunk as PcmChunkMessage;
const pcmBuffer = pcmChunk.readFrames(readFrames - read);
const normalize: number = 2 ** pcmChunk.sampleFormat.bits;
// Signed PCM peaks at 2^(bits-1) (e.g. 32767 for 16-bit), so
// normalize to [-1, 1) by dividing by 2^(bits-1), not 2^bits
// (the latter played everything ~6 dB too quiet).
const normalize: number = 2 ** (pcmChunk.sampleFormat.bits - 1);
let payload: any;
if (pcmChunk.sampleFormat.bits >= 24)
payload = new Int32Array(pcmBuffer);
Expand Down Expand Up @@ -549,7 +562,7 @@ class TimeProvider {
if (this.diffBuffer.push((c2s - s2c) / 2) > 100)
this.diffBuffer.shift();
const sorted = [...this.diffBuffer];
sorted.sort()
sorted.sort((a, b) => a - b);
this.diff = sorted[Math.floor(sorted.length / 2)];
}
// console.debug("c2s: " + c2s.toFixed(2) + ", s2c: " + s2c.toFixed(2) + ", diff: " + this.diff.toFixed(2) + ", now: " + this.now().toFixed(2) + ", server.now: " + this.serverNow().toFixed(2) + ", win.now: " + window.performance.now().toFixed(2));
Expand Down