Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions src/assets/Icons/Icons/IconBluetooth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Icon } from '.'

function IconBluetooth(props): JSX.Element {
return (
<Icon {...props} fill="currentColor">
<svg viewBox="0 0 24 24">
<path d="M17.71,7.71L12,2H11V9.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L11,14.41V22H12L17.71,16.29L13.41,12L17.71,7.71M13,5.83L15.17,8L13,10.17V5.83M15.17,16L13,18.17V13.83L15.17,16Z" />
</svg>
</Icon>
)
}

export default IconBluetooth
13 changes: 13 additions & 0 deletions src/assets/Icons/Icons/IconUsb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Icon } from '.'

function IconUsb(props): JSX.Element {
return (
<Icon {...props} fill="currentColor">
<svg viewBox="0 0 24 24">
<path d="M15,7V11H16V13H13V5H15L12,1L9,5H11V13H8V10.93C8.7,10.56 9.2,9.85 9.2,9C9.2,7.78 8.21,6.8 7,6.8C5.78,6.8 4.8,7.78 4.8,9C4.8,9.85 5.3,10.56 6,10.93V13C6,14.11 6.89,15 8,15H11V18.05C10.29,18.41 9.8,19.15 9.8,20C9.8,21.21 10.79,22.2 12,22.2C13.21,22.2 14.2,21.21 14.2,20C14.2,19.15 13.71,18.41 13,18.05V15H16C17.11,15 18,14.11 18,13V11H19V7H15Z" />
</svg>
</Icon>
)
}

export default IconUsb
2 changes: 2 additions & 0 deletions src/assets/Icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
11 changes: 11 additions & 0 deletions src/components/websocketListener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
63 changes: 56 additions & 7 deletions src/overlay/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -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<Transport>('unknown')

useEffect(() => {
if (!isConnected || !context?.ip || !context?.port) return
let cancelled = false

const probe = async (): Promise<void> => {
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 (
<div className="fixed top-2 left-2 z-40 flex items-center rounded-full bg-black/40 px-2 py-1 text-neutral-300">
{transport === 'bluetooth' ? (
<IconBluetooth iconSize={14} className="text-sky-400" />
) : transport === 'usb' ? (
<IconUsb iconSize={14} className="text-neutral-300" />
) : (
<IconConnected iconSize={14} className="text-neutral-400" />
)}
{label && <span className="ml-1 text-[10px] font-semibold tracking-wide">{label}</span>}
</div>
)
}

return (
<div className="fixed top-4 left-4 z-40 flex items-center rounded-lg bg-rose-950 px-4 py-2 text-sm text-white">
{isReconnecting ? (
<div className="flex items-center">
<IconLoading iconSize={12} strokeWidth={5} className='animate-spin mr-2' />
<IconLoading iconSize={12} strokeWidth={5} className="animate-spin mr-2" />
<p>Reconnecting</p>
</div>
) : (
Expand Down
93 changes: 93 additions & 0 deletions src/overlay/PairingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<PairingState | null>(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<void> => {
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 (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
<p className={`text-4xl font-bold ${flash === 'ok' ? 'text-green-400' : 'text-rose-400'}`}>
{flash === 'ok' ? 'Paired!' : 'Pairing failed'}
</p>
{flash === 'ok' && (
<p className="text-neutral-400 mt-3 text-xl">Connecting to your computer…</p>
)}
</div>
)
}

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

return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/95">
<p className="text-neutral-300 text-2xl mb-6">Bluetooth pairing request</p>
<p className="text-white font-mono text-8xl tracking-[0.3em] mb-8">{state.passkey}</p>
<p className="text-neutral-400 text-xl max-w-lg text-center">
Confirm this code on your computer to finish pairing
</p>
</div>
)
}

export default PairingOverlay
2 changes: 2 additions & 0 deletions src/overlay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -49,6 +50,7 @@ const Overlays: React.FC<OverlayProps> = ({ children }) => {
<div className="flex bg-black flex-col w-screen max-h-screen h-screen items-center justify-end">
{!preferences.onboarding || <AppTray />}
<ServerStatus />
<PairingOverlay />
<NotificationOverlay />
<VolumeOverlay />
{!isConnected && <ScreenSaverWrapper />}
Expand Down
23 changes: 21 additions & 2 deletions src/stores/musicStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>": 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<MusicState>((set, get) => ({
song: null,
setSong: (newData) => {
Expand All @@ -49,11 +63,16 @@ export const useMusicStore = create<MusicState>((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}`
)
}
}

Expand Down
Loading