diff --git a/src/components/Client.tsx b/src/components/Client.tsx index b23dd76..9f384ff 100644 --- a/src/components/Client.tsx +++ b/src/components/Client.tsx @@ -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(null); const [open, setOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); @@ -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 = []; diff --git a/src/components/Group.tsx b/src/components/Group.tsx index 3831e3a..85f13fc 100644 --- a/src/components/Group.tsx +++ b/src/components/Group.tsx @@ -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([]); const [streamId, setStreamId] = useState(""); @@ -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; @@ -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) { @@ -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) { @@ -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) { diff --git a/src/components/SnapWeb.tsx b/src/components/SnapWeb.tsx index 73a302e..7dcb7cc 100644 --- a/src/components/SnapWeb.tsx +++ b/src/components/SnapWeb.tsx @@ -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); @@ -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"); @@ -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(); } @@ -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) { diff --git a/src/snapstream.ts b/src/snapstream.ts index 736e348..72a099b 100644 --- a/src/snapstream.ts +++ b/src/snapstream.ts @@ -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); @@ -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; @@ -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); @@ -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));