diff --git a/package-lock.json b/package-lock.json index fd80ed1..68f2ee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deskthing-client", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deskthing-client", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "clsx": "^2.1.1", "react": "^18.3.1", diff --git a/src/assets/Icons/Icons/IconBluetooth.tsx b/src/assets/Icons/Icons/IconBluetooth.tsx new file mode 100644 index 0000000..9f7ea2e --- /dev/null +++ b/src/assets/Icons/Icons/IconBluetooth.tsx @@ -0,0 +1,13 @@ +import { Icon } from '.' + +function IconBluetooth(props): JSX.Element { + return ( + + + + + + ) +} + +export default IconBluetooth diff --git a/src/assets/Icons/Icons/IconUsb.tsx b/src/assets/Icons/Icons/IconUsb.tsx new file mode 100644 index 0000000..4fe61d4 --- /dev/null +++ b/src/assets/Icons/Icons/IconUsb.tsx @@ -0,0 +1,13 @@ +import { Icon } from '.' + +function IconUsb(props): JSX.Element { + return ( + + + + + + ) +} + +export default IconUsb diff --git a/src/assets/Icons/index.ts b/src/assets/Icons/index.ts index 9cd7708..f9ce532 100644 --- a/src/assets/Icons/index.ts +++ b/src/assets/Icons/index.ts @@ -53,3 +53,5 @@ export { default as IconVolUp } from './Icons/IconVolUp' export { default as IconVolDown } from './Icons/IconVolDown' export { default as IconFullscreen } from './Icons/IconFullscreen' export { default as IconFullscreenReverse } from './Icons/IconFullscreenReverse' +export { default as IconBluetooth } from './Icons/IconBluetooth' +export { default as IconUsb } from './Icons/IconUsb' diff --git a/src/components/websocketListener.tsx b/src/components/websocketListener.tsx index 202746c..6128e23 100644 --- a/src/components/websocketListener.tsx +++ b/src/components/websocketListener.tsx @@ -14,8 +14,19 @@ import { handleServerSocket } from '@src/utils/serverWebsocketHandler' export const WebSocketListener = () => { const setSong = useMusicStore((store) => store.setSong) const getSong = useMusicStore((store) => store.requestMusicData) + const isConnected = useWebSocketStore((store) => store.isConnected) const [prevTrackName, setPrevTrackName] = useState('') + // Ask for the current track as soon as we are connected. Without this the + // only path to song data is an unsolicited push, so after any reconnect the + // screen stays blank until the track happens to change or the server's + // refresh interval comes round — seconds of "Waiting For Track…" while the + // connection is actually fine. Most visible over Bluetooth, where the link + // legitimately drops and returns. + useEffect(() => { + if (isConnected) getSong(true) + }, [isConnected, getSong]) + useEffect(() => { const websocketManager = useWebSocketStore.getState() diff --git a/src/overlay/ConnectionStatus.tsx b/src/overlay/ConnectionStatus.tsx index 5b21821..0c158a6 100644 --- a/src/overlay/ConnectionStatus.tsx +++ b/src/overlay/ConnectionStatus.tsx @@ -1,26 +1,75 @@ -import { IconLoading } from '@src/assets/Icons' +import { IconBluetooth, IconConnected, IconLoading, IconUsb } from '@src/assets/Icons' import { useWebSocketStore } from '@src/stores/' +import { useSettingsStore } from '@src/stores/settingsStore' import { useUIStore } from '@src/stores/uiStore' +import { useEffect, useState } from 'react' + +type Transport = 'bluetooth' | 'usb' | 'unknown' /** - * Renders a component that displays the connection status of the WebSocket connection. + * Renders the connection status of the WebSocket connection. + * + * While connected, shows a small badge naming the transport actually carrying the + * data. The transport is probed rather than assumed: /__bt is answered locally by + * the Bluetooth mux when it owns the port, so a JSON reply means Bluetooth, while + * anything else means the request travelled to the server over USB. * - * If the WebSocket connection is connected, this component will not render anything. - * If the WebSocket connection is disconnected, this component will render a fixed - * notification at the top-left of the screen indicating the disconnection status. + * While disconnected or reconnecting, shows the original notice instead. */ export const ServerStatus = () => { const isConnected = useWebSocketStore((state) => state.isConnected) const isReconnecting = useWebSocketStore((state) => state.isReconnecting) const isScreensaverActive = useUIStore((state) => state.isScreensaverActive) + const context = useSettingsStore((state) => state.manifest.context) + const [transport, setTransport] = useState('unknown') + + useEffect(() => { + if (!isConnected || !context?.ip || !context?.port) return + let cancelled = false + + const probe = async (): Promise => { + try { + const res = await fetch(`http://${context.ip}:${context.port}/__bt`, { + cache: 'no-store' + }) + const data = res.ok ? await res.json() : null + if (!cancelled) setTransport(data?.transport === 'bluetooth' ? 'bluetooth' : 'usb') + } catch { + if (!cancelled) setTransport('usb') + } + } + + probe() + const id = setInterval(probe, 15000) + return () => { + cancelled = true + clearInterval(id) + } + }, [isConnected, context?.ip, context?.port]) + + if (isScreensaverActive) return null - if (isConnected || isScreensaverActive) return null + if (isConnected) { + const label = transport === 'bluetooth' ? 'BT' : transport === 'usb' ? 'USB' : '' + return ( +
+ {transport === 'bluetooth' ? ( + + ) : transport === 'usb' ? ( + + ) : ( + + )} + {label && {label}} +
+ ) + } return (
{isReconnecting ? (
- +

Reconnecting

) : ( diff --git a/src/overlay/PairingOverlay.tsx b/src/overlay/PairingOverlay.tsx new file mode 100644 index 0000000..ab81cf0 --- /dev/null +++ b/src/overlay/PairingOverlay.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' + +/** + * Fullscreen pairing code display, shown when the computer initiates + * Bluetooth pairing with this device — the original Car Thing flow: the + * code appears here, the person confirms it on the computer. + * + * State comes from the on-device pairing agent's local endpoint, which + * exists only on provisioned hardware; anywhere else the fetch fails once + * and the overlay stays dormant. + */ + +const AGENT_URL = 'http://127.0.0.1:8892/pairing' +// Poll fast: a pairing exchange can resolve in a couple of seconds, and the +// code must be on screen the whole time the person is comparing it. +const POLL_MS = 500 + +type PairingState = { + active: boolean + passkey: string | null + result: 'ok' | 'failed' | null +} + +export const PairingOverlay = () => { + const [state, setState] = useState(null) + const [dead, setDead] = useState(false) + const [flash, setFlash] = useState<'ok' | 'failed' | null>(null) + + useEffect(() => { + if (dead) return + let cancelled = false + let misses = 0 + + const poll = async (): Promise => { + try { + const res = await fetch(AGENT_URL, { cache: 'no-store' }) + if (!res.ok) throw new Error() + const data = (await res.json()) as PairingState + if (cancelled) return + misses = 0 + setState((prev) => { + // Show a brief success/failure flash when a pairing round ends. + if (prev?.active && !data.active && data.result) setFlash(data.result) + return data + }) + } catch { + if (cancelled) return + // No agent (not a provisioned Car Thing) — stop polling entirely. + if (++misses >= 2) setDead(true) + } + } + + poll() + const id = setInterval(poll, POLL_MS) + return () => { + cancelled = true + clearInterval(id) + } + }, [dead]) + + useEffect(() => { + if (!flash) return + const id = setTimeout(() => setFlash(null), 4000) + return () => clearTimeout(id) + }, [flash]) + + if (flash) { + return ( +
+

+ {flash === 'ok' ? 'Paired!' : 'Pairing failed'} +

+ {flash === 'ok' && ( +

Connecting to your computer…

+ )} +
+ ) + } + + if (!state?.active || !state.passkey) return null + + return ( +
+

Bluetooth pairing request

+

{state.passkey}

+

+ Confirm this code on your computer to finish pairing +

+
+ ) +} + +export default PairingOverlay diff --git a/src/overlay/index.tsx b/src/overlay/index.tsx index aa6bef9..ea669d7 100644 --- a/src/overlay/index.tsx +++ b/src/overlay/index.tsx @@ -8,6 +8,7 @@ import { useActionStore } from '@src/stores/actionStore' import { useEffect, useMemo, useState } from 'react' import YouAreHere from './YouAreHere' import { ServerStatus } from './ConnectionStatus' +import { PairingOverlay } from './PairingOverlay' import ScreenSaverWrapper from './ScreenSaver/ScreenSaverWrapper' interface OverlayProps { @@ -49,6 +50,7 @@ const Overlays: React.FC = ({ children }) => {
{!preferences.onboarding || } + {!isConnected && } diff --git a/src/stores/musicStore.ts b/src/stores/musicStore.ts index 3bebd33..a9a6d21 100644 --- a/src/stores/musicStore.ts +++ b/src/stores/musicStore.ts @@ -34,6 +34,20 @@ export interface MusicState { setShuffle: () => void } +/** + * Spotify encodes the artwork size in the CDN path prefix: 0000b273 is 640x640 + * (~110KB) while 00001e02 is 300x300 (~36KB). The Car Thing panel is 800x480, so + * the 640px asset is mostly wasted bytes — and on a Bluetooth-tunneled connection + * (~155KB/s) it costs about 0.7s of a saturated link on every track change. + * Non-Spotify URLs are left untouched. + */ +const preferSmallerArtwork = (url: string): string => + // Match the bare id prefix rather than "/image/": by the time we see the + // URL the server has usually wrapped it in /proxy/v1?url=..., where the + // slashes are percent-encoded but the id is not. Keying off the id alone + // works for both the raw and the wrapped form. + url.replace(/ab67616d0000b273/g, 'ab67616d00001e02') + export const useMusicStore = create((set, get) => ({ song: null, setSong: (newData) => { @@ -49,11 +63,16 @@ export const useMusicStore = create((set, get) => ({ if (context.id == ClientPlatformIDs.CarThing || context.ip == 'localhost') { if (newData.thumbnail.includes(`${context.ip}:${context.port}`)) return // already parsed as a corrected IP - newData.thumbnail = `http://${context.ip}:${context.port}/proxy/v1?url=${encodeURIComponent(newData.thumbnail)}` + newData.thumbnail = `http://${context.ip}:${context.port}/proxy/v1?url=${encodeURIComponent(preferSmallerArtwork(newData.thumbnail))}` } } else if (newData.thumbnail.startsWith('/')) { + // The server normally hands us a relative /proxy/v1?url=... — this is + // the path Spotify artwork actually takes, so the size preference has + // to be applied here too or it never runs at all. const context = useSettingsStore.getState().manifest?.context - newData.thumbnail = `http://${context.ip}:${context.port}${newData.thumbnail}` + newData.thumbnail = preferSmallerArtwork( + `http://${context.ip}:${context.port}${newData.thumbnail}` + ) } } diff --git a/src/utils/websocketManager.ts b/src/utils/websocketManager.ts index 546d120..8ee7e47 100644 --- a/src/utils/websocketManager.ts +++ b/src/utils/websocketManager.ts @@ -8,6 +8,10 @@ import { type SocketEventListener = (msg: DeskThingToDeviceCore & { app?: string }) => void type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting' + +/** Reconnect backoff: quick first attempt, then ease off to this ceiling. */ +const RECONNECT_BASE_MS = 500 +const RECONNECT_MAX_MS = 10000 type StatusListener = (status: ConnectionStatus) => void /** @@ -19,6 +23,7 @@ export class WebSocketManager { private listeners: SocketEventListener[] = [] private statusListeners: StatusListener[] = [] private reconnecting = false + private reconnectAttempts = 0 private url: string private heartbeatInterval: NodeJS.Timeout | null = null private pongTimeout: NodeJS.Timeout | null = null @@ -52,8 +57,16 @@ export class WebSocketManager { this.socket.onerror = null this.socket.onmessage = null - // Close connection if open - if (this.socket.readyState === WebSocket.OPEN) { + // Close the connection if it is open OR still connecting. A socket left + // in CONNECTING (e.g. the server was unreachable when we dialed — the + // case on a Bluetooth link that comes up ~80s after the device boots) + // must be closed too, or it lingers half-open, holds a tunnel stream, + // and blocks the client from ever cleanly reconnecting once the link is + // up. Only closing OPEN sockets leaked these on every failed attempt. + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { await this.socket.close(4000, 'Closing existing connection') } @@ -97,6 +110,7 @@ export class WebSocketManager { clearTimeout(timeout) console.info(`[${id}] Connected to ${this.url}`) this.reconnecting = false + this.reconnectAttempts = 0 // connected: start the backoff over this.startHeartbeat() this.notifyStatusChange('connected') resolve() @@ -112,6 +126,7 @@ export class WebSocketManager { this.socket.onopen = () => { console.info(`[${id}] Connected to ${this.url}`) this.reconnecting = false + this.reconnectAttempts = 0 // connected: start the backoff over this.startHeartbeat() this.notifyStatusChange('connected') } @@ -125,9 +140,12 @@ export class WebSocketManager { clearTimeout(this.closeTimeoutId) } + // Retry promptly. This used to wait 5s here and then a further 10s in + // reconnect(), so the first attempt was 15s after a drop no matter how + // briefly the link had gone away. this.closeTimeoutId = setTimeout(() => { this.reconnect() - }, 5000) + }, 250) } this.socket.onerror = (error) => { @@ -167,7 +185,7 @@ export class WebSocketManager { this.closeTimeoutId = setTimeout(() => { this.reconnect() - }, 5000) + }, 250) return false } } @@ -177,7 +195,6 @@ export class WebSocketManager { } reconnect() { - Logger.info('Reconnecting in 10s...') if (this.reconnecting) return this.reconnecting = true this.notifyStatusChange('reconnecting') @@ -188,11 +205,21 @@ export class WebSocketManager { clearTimeout(this.reconnectId) } + // Back off instead of waiting a flat 10s. A dropped link usually comes + // back immediately, so the first retry should be quick; only a genuinely + // absent server should push the delay out. + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, + RECONNECT_MAX_MS + ) + this.reconnectAttempts += 1 + Logger.info(`Reconnecting in ${delay}ms...`) + this.reconnectId = setTimeout(() => { Logger.info('Conecting...') this.connect() this.reconnecting = false - }, 10000) // Reconnect after 10 seconds + }, delay) } sendMessage(message: DeviceToDeskthingData) {