@@ -452,5 +457,6 @@ export const ReplayViewer = {
{{ replayDetail.reason }}
{{ seekGaps.length }} missing recording {{ seekGaps.length === 1 ? 'range' : 'ranges' }}
Missing recordings: {{ i ? ", " : "" }}{{ replayTime(gap[0]-replay.start) }}–{{ replayTime(gap[1]-replay.start) }}. Seeks move to the nearest recorded position.
+
`,
}
diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js
index d5ef7e7a79..07dcdc95be 100644
--- a/openpilot/starpilot/galaxy/web/js/sentry.js
+++ b/openpilot/starpilot/galaxy/web/js/sentry.js
@@ -1,6 +1,7 @@
import { GxState } from "./state.js"
import { SettingsPage } from "./settings.js"
import { GxNotice } from "./notice.js"
+import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { SentryNotifications } from "./sentry-notifications.js"
import { SnapshotFeed } from "./snapshot-feed.js"
import { deleteRecords, storedSize } from "./record-deletion.js"
@@ -48,10 +49,10 @@ export class SentryEventsFeed extends SnapshotFeed {
export const SentryEventsPage = {
name: "SentryEventsPage",
- components: { GxState, GxNotice, SentryNotifications, SettingsPage },
+ components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
- data: () => ({ status: "idle", data: null, error: "", deleting: false }),
+ data: () => ({ bluetooth: bluetoothConnection(), status: "idle", data: null, error: "", deleting: false }),
created() { this.feed = new SentryEventsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() }
@@ -76,12 +77,13 @@ export const SentryEventsPage = {
{{ data.events.length }} events · {{ data.storageIncomplete ? 'At least ' : '' }}{{ storedSize(data.storageBytes) }} stored
This scan was incomplete. More local motion events may exist.
No local motion events found in this scan.
+
System time: {{ systemTime(event.systemTimeMs) }}
Event ID: {{ event.eventId }}
-
+
diff --git a/openpilot/starpilot/galaxy/web/js/share-destination.js b/openpilot/starpilot/galaxy/web/js/share-destination.js
new file mode 100644
index 0000000000..a1e29f4de4
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/share-destination.js
@@ -0,0 +1,21 @@
+// Destination coordinates take precedence over Google's viewport (@lat,lng).
+export function parseSharedDestination(input) {
+ let text = String(input || '').slice(0, 8192).trim()
+ try { text = decodeURIComponent(text.replace(/\+/g, ' ')) } catch { /* Keep malformed shares searchable. */ }
+ const number = '(-?\\d+(?:\\.\\d+)?)'
+ const patterns = [new RegExp(`!3d${number}!4d${number}`),
+ new RegExp(`(?:[?&:](?:q|query|daddr|destination)=)${number},\\s*${number}`),
+ new RegExp(`geo:${number},\\s*${number}`), new RegExp(`@${number},\\s*${number}`)]
+ for (const pattern of patterns) {
+ const match = text.match(pattern)
+ if (!match) continue
+ const latitude = Number(match[1]), longitude = Number(match[2])
+ if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) continue
+ // geo:0,0?q=an address is a search, not a destination in the ocean.
+ if (match[0].startsWith('geo:0,0') && /[?&:]q=/.test(text)) continue
+ const label = text.match(/\(([^()]{1,120})\)/)?.[1] || text.match(/\/place\/([^/]+)/)?.[1]
+ return { destination: { name: label || 'Shared destination', latitude, longitude } }
+ }
+ const query = text.match(/[?&:](?:q|query|daddr|destination)=([^]+)/)?.[1] || text
+ return { query: query.slice(0, 200) }
+}
diff --git a/openpilot/starpilot/galaxy/web/js/spotify.js b/openpilot/starpilot/galaxy/web/js/spotify.js
new file mode 100644
index 0000000000..f6c5b957b9
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/spotify.js
@@ -0,0 +1,725 @@
+import { GxDialog } from "./dialog.js"
+import { requestJson } from "./startup.js"
+import { GxNotice } from "./notice.js"
+import { connectionError } from "./polling.js"
+import { uploadPackage } from "./android-auto.js"
+import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
+
+const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
+export const DOWNLOAD_PAGE = "https://developer.spotify.com/documentation/soloist/reference/downloads-and-updates"
+const STAGES = ["reading", "unpacking", "verifying", "testing", "installing", "done"]
+const CHECKS = [
+ { stage: "reading", label: "Received the download", codes: ["TOO_LARGE"] },
+ { stage: "unpacking", label: "Unpacked safely", codes: ["NOT_ARCHIVE", "CORRUPT", "UNSAFE", "NO_PLAYER", "WRONG_ARCH"] },
+ { stage: "verifying", label: "Matches Spotify’s official download", codes: ["NOT_OFFICIAL", "OFFLINE"] },
+ { stage: "testing", label: "Runs on this comma", codes: ["INCOMPATIBLE", "EXPIRED"] },
+ { stage: "installing", label: "Installed", codes: ["DRIVING"] },
+]
+
+function validSetup(value) {
+ return value && typeof value.enabled === "boolean" && typeof value.parked === "boolean" && typeof value.keyPresent === "boolean" &&
+ value.release && typeof value.release.installed === "boolean" && value.preferences && Array.isArray(value.devices) &&
+ value.import && Number.isSafeInteger(value.maxUploadBytes) && value.maxUploadBytes > 0 && value.links
+}
+
+// Name and size checks that catch the usual wrong downloads before sending anything.
+export function archivePreflight(file) {
+ const name = typeof file?.name === "string" ? file.name : ""
+ if (/arm32|armv7|x86_64|amd64/i.test(name)) return "That’s the download for a different computer. Choose ARMv8/AArch64 Linux (soloist_release_arm64.tar.gz)."
+ if (/\.tar$/i.test(name)) return "Your browser unpacked the download. Download it again and keep the .tar.gz file as it is."
+ if (/^soloist$/i.test(name)) return "That’s the unpacked player. Upload the .tar.gz file exactly as it downloaded."
+ if (name && !/\.(tar\.gz|tgz|gz)$/i.test(name)) return "Choose the soloist_release_arm64.tar.gz file you downloaded."
+ return ""
+}
+
+export function importProblem(job) {
+ if (job?.state !== "failed") return ""
+ switch (job.code) {
+ case "NOT_ARCHIVE": return "That isn’t the Soloist download. Choose soloist_release_arm64.tar.gz."
+ case "CORRUPT": return "This file looks incomplete or damaged. Download it again and wait for it to finish."
+ case "WRONG_ARCH": return "That’s the download for a different computer. On Spotify’s page, choose ARMv8/AArch64 Linux."
+ case "NO_PLAYER": case "UNSAFE": return "This archive isn’t a Soloist release. Download it again from Spotify’s page."
+ case "NOT_OFFICIAL": return "This file doesn’t match the build Spotify is offering right now. Spotify may have just released a new one; download it again and upload the new file."
+ case "OFFLINE": return "Your comma needs an internet connection to check this download with Spotify. Connect it to Wi‑Fi or cellular and try again."
+ case "EXPIRED": return "This Soloist build has expired. Download the newest one from Spotify."
+ case "INCOMPATIBLE": return job.error || "This Soloist build doesn’t run on this comma yet."
+ case "DRIVING": return "Put the car in Park or use offroad mode, then install again."
+ case "TOO_LARGE": return "This file is too large to be the Soloist download."
+ case "CANCELLED": return "Setup authorization changed. Sign in to Galaxy and try again."
+ default: return job.error || "Something went wrong. Try again."
+ }
+}
+
+export function installChecks(job) {
+ const at = job?.state === "done" ? STAGES.length : STAGES.indexOf(job?.stage)
+ const ends = CHECKS.map((_, index) => STAGES.indexOf(index + 1 < CHECKS.length ? CHECKS[index + 1].stage : "done"))
+ let failed = -1
+ if (job?.state === "failed") {
+ failed = CHECKS.findIndex((check) => check.codes.includes(job.code))
+ if (failed < 0) failed = Math.max(0, ends.findIndex((end) => at < end))
+ }
+ return CHECKS.map((check, index) => ({
+ label: check.label,
+ status: failed >= 0 ? (index < failed ? "done" : index === failed ? "failed" : "pending") :
+ at >= ends[index] ? "done" : at >= STAGES.indexOf(check.stage) ? "active" : "pending",
+ }))
+}
+
+export function releaseInfo(release, locale = undefined) {
+ if (!release?.installed) return { level: "none", label: "Not installed" }
+ const date = release.expires ? new Date(release.expires) : null
+ const on = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" }) : ""
+ const version = release.version ? `Version ${release.version}` : "Installed"
+ if (release.expired) return { level: "expired", label: `${version} · expired${on ? ` on ${on}` : ""}` }
+ if (release.level === "soon") {
+ const left = release.daysLeft === 0 ? "expires within a day" : `expires in ${release.daysLeft} day${release.daysLeft === 1 ? "" : "s"}`
+ return { level: "soon", label: `${version} · ${left}${on ? ` (${on})` : ""}` }
+ }
+ return { level: "ok", label: `${version} · ${on ? `expires on ${on}` : "expiry date unavailable"}` }
+}
+
+const STATE_TEXT = {
+ missing_package: "Install Spotify Soloist to finish setup.",
+ missing_key: "Add your API key to finish setup.",
+ no_output: "Choose where music plays.",
+ audio_unavailable: "This StarPilot build is missing Spotify’s audio component.",
+ waiting_output: "Waiting for your speaker to connect.",
+ starting: "Starting…",
+ connecting: "Starting…",
+ waiting: "Ready to pair. Open Spotify on your phone and pick this device.",
+ ready: "Ready. Pick this device in Spotify to play.",
+ playing: "Playing",
+ retrying: "Restarting…",
+ failed: "Spotify couldn’t start.",
+ expired: "This Soloist build expired. Install the newest one.",
+}
+
+export function serviceLabel(setup) {
+ if (!setup?.enabled) return "Off"
+ const service = setup.service
+ if (!service) return "Starting…"
+ if (service.state === "ready" && service.player?.status === "paused") return "Paused"
+ return service.message && !["ready", "playing", "waiting"].includes(service.state) ? service.message : STATE_TEXT[service.state] || "Starting…"
+}
+
+// Where the track is now, from Soloist's anchor and the browser time elapsed since it.
+export function positionNow(position, durationMs, now = Date.now()) {
+ if (!position) return 0
+ const value = position.ms + Math.max(0, now - position.at) * position.speed
+ return Math.max(0, Math.min(durationMs ?? value, value))
+}
+
+// Moves the play anchor onto the browser's clock, since the comma's and the browser's can differ.
+export function onBrowserClock(setup, now = Date.now()) {
+ const position = setup?.service?.player?.position
+ if (!position || !Number.isFinite(setup.clock)) return setup
+ const player = { ...setup.service.player, position: { ...position, at: position.at + now - setup.clock } }
+ return { ...setup, service: { ...setup.service, player } }
+}
+
+export function formatTime(ms) {
+ const seconds = Math.floor((ms || 0) / 1000)
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
+}
+
+export class SpotifyFeed {
+ constructor({ publish, unauthorized = () => { }, fetcher = (...args) => fetch(...args), uploader = uploadPackage,
+ later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id),
+ overBluetooth = () => globalThis.StarPilotApp?.bluetooth?.() === true }) {
+ Object.assign(this, { publish, unauthorized, fetcher, uploader, later, cancelTimer, overBluetooth })
+ this.active = false
+ this.generation = 0
+ this.pollTimer = null
+ this.setup = null
+ this.busy = false
+ this.uploadProgress = null
+ this.error = ""
+ }
+
+ emit() { this.publish({ setup: this.setup, busy: this.busy, uploadProgress: this.uploadProgress, error: this.error }) }
+
+ start() { this.stop(); this.active = true; return this.refresh() }
+
+ stop() {
+ this.active = false
+ this.generation++
+ if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
+ this.pollTimer = null
+ this.busy = false
+ this.uploadProgress = null
+ this.emit()
+ }
+
+ schedule() {
+ if (!this.active) return
+ if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
+ const fast = this.setup?.import?.state === "running" || this.setup?.service?.player?.connected
+ // The phone Bluetooth link is slow, so poll it a lot less.
+ const delay = this.overBluetooth() ? (fast ? 5000 : 15000) : (fast ? 1500 : 4000)
+ this.pollTimer = this.later(() => { this.pollTimer = null; this.refresh() }, delay)
+ }
+
+ async request(path, options = {}, timeout = 8000, transport = this.fetcher) {
+ const generation = this.generation
+ try {
+ const { response, data } = await requestJson(path, {
+ fetcher: transport, timeout, later: this.later, cancel: this.cancelTimer, request: options, withResponse: true
+ })
+ if (!this.active || this.generation !== generation) return null
+ if (response.status === 401) { this.stop(); this.unauthorized(); return null }
+ if (!response.ok) throw new Error(data?.error || "Spotify setup is unavailable")
+ return data
+ } catch (error) {
+ if (this.active && this.generation === generation) {
+ this.error = error.name === "TimeoutError" ? "Your comma took too long to respond. Retrying…" : connectionError(error)
+ this.emit()
+ }
+ return null
+ }
+ }
+
+ async refresh() {
+ if (!this.active) return
+ const setup = await this.request("./api/spotify/setup")
+ if (setup && validSetup(setup)) { this.setup = onBrowserClock(setup); if (!this.busy) this.error = "" }
+ else if (setup) this.error = "Spotify setup response changed"
+ this.emit()
+ this.schedule()
+ }
+
+ async post(path, body, timeout = 10000) {
+ if (!this.active || this.busy) return null
+ this.busy = true
+ this.error = ""
+ this.emit()
+ const result = await this.request(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, timeout)
+ this.busy = false
+ if (result && validSetup(result)) this.setup = onBrowserClock(result)
+ this.emit()
+ if (result) this.refresh()
+ return result
+ }
+
+ // Playback controls stay outside the busy gate so a slow command never blocks the next tap.
+ async control(action, args = undefined) {
+ if (!this.active) return null
+ const result = await this.request("./api/spotify/control", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(args === undefined ? { action } : { action, args })
+ }, 5000)
+ if (result) this.later(() => this.refresh(), 400)
+ return result
+ }
+
+ async upload(file) {
+ if (!this.active || this.busy || !this.setup || this.setup.uploadAllowed === false || this.setup.import?.state === "running" ||
+ !file || !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.setup.maxUploadBytes) return false
+ this.busy = true
+ this.uploadProgress = { loaded: 0, total: file.size }
+ this.emit()
+ const generation = this.generation
+ const transport = (path, options) => this.uploader(path, options, (progress) => {
+ if (this.active && generation === this.generation) { this.uploadProgress = progress; this.emit() }
+ })
+ const result = await this.request("./api/spotify/upload", {
+ method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file
+ }, 180000, transport)
+ this.busy = false
+ this.uploadProgress = null
+ if (result && validSetup(result)) this.setup = onBrowserClock(result)
+ this.emit()
+ this.schedule()
+ return result !== null
+ }
+
+ async removeAll() {
+ if (!this.active || this.busy) return false
+ this.busy = true
+ this.emit()
+ const result = await this.request("./api/spotify", { method: "DELETE" }, 30000)
+ this.busy = false
+ if (result && validSetup(result)) this.setup = onBrowserClock(result)
+ this.emit()
+ return result !== null
+ }
+}
+
+export const SpotifyPage = {
+ components: { GxNotice, GxDialog, GalaxySettingRow },
+ props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
+ data: () => ({
+ setup: null, busy: false, error: "", uploadProgress: null, now: Date.now(),
+ installOpen: false, removeOpen: false, resetOpen: false, showSetup: false, downloadStarted: false,
+ archiveFile: null, archiveMagicError: "", archiveChecking: false, archiveSelection: 0, installAttempted: false, installBaseline: null,
+ keyValue: "", keyEditing: false, nameValue: "", volumeDraft: null,
+ }),
+ mounted() {
+ this.feed = new SpotifyFeed({ publish: (value) => Object.assign(this, value, { now: Date.now() }), unauthorized: this.unauthorized })
+ this.visibility = () => { if (document.hidden) this.feed.stop(); else this.feed.start() }
+ document.addEventListener("visibilitychange", this.visibility)
+ this.clock = setInterval(() => { if (this.playing) this.now = Date.now() }, 1000)
+ if (!document.hidden) this.feed.start()
+ },
+ beforeUnmount() {
+ document.removeEventListener("visibilitychange", this.visibility)
+ clearInterval(this.clock)
+ this.keyValue = ""
+ this.feed?.stop()
+ },
+ watch: {
+ mode() { if (!document.hidden) this.feed.start(); else this.feed.stop() },
+ "setup.preferences.deviceName": { immediate: true, handler(value) { if (typeof value === "string") this.nameValue = value } },
+ },
+ computed: {
+ release() { return releaseInfo(this.setup?.release) },
+ installed() { return !!this.setup?.release.installed && !this.setup.release.expired },
+ outputChosen() {
+ const prefs = this.setup?.preferences
+ return !!prefs && (["test", "android_auto"].includes(prefs.output) || ADDRESS.test(prefs.outputAddress))
+ },
+ phoneSource() { return this.setup?.preferences.source === "phone" },
+ setupDone() { return this.phoneSource ? !!(ADDRESS.test(this.setup?.preferences.phoneAddress || "") && this.outputChosen) :
+ !!(this.installed && this.setup?.keyPresent && this.outputChosen) },
+ player() { return this.setup?.service?.player || null },
+ playing() { return this.player?.status === "playing" },
+ track() { return this.player?.loggedIn ? this.player.track : null },
+ can() { return new Set(this.player?.actions || []) },
+ elapsed() { return positionNow(this.player?.position, this.track?.durationMs, this.now) },
+ statusText() { return serviceLabel(this.setup) },
+ outputName() {
+ const prefs = this.setup?.preferences
+ if (!prefs) return ""
+ if (prefs.output === "android_auto") return "Android Auto"
+ return prefs.output === "test" ? "Silent test output" : prefs.outputName || prefs.outputAddress || "Not chosen"
+ },
+ uploadPercent() {
+ const { loaded, total } = this.uploadProgress || {}
+ return total > 0 ? Math.min(100, Math.round(100 * loaded / total)) : 0
+ },
+ onPhone() {
+ return typeof navigator !== "undefined" && (navigator.userAgentData?.mobile === true ||
+ /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "") || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
+ },
+ downloadsHint() {
+ if (typeof navigator === "undefined") return ""
+ if (/iPhone|iPad|iPod/i.test(navigator.userAgent || "") || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "In Files, open Downloads."
+ return /Android/i.test(navigator.userAgent || "") ? "Open Downloads." : ""
+ },
+ archiveError() {
+ if (!this.archiveFile) return ""
+ const preflight = archivePreflight(this.archiveFile)
+ if (preflight) return preflight
+ if (!Number.isSafeInteger(this.archiveFile.size) || this.archiveFile.size <= 0) return "The selected file is empty or its size cannot be read."
+ if (this.setup && this.archiveFile.size > this.setup.maxUploadBytes) return "This file is too large to be the Soloist download."
+ return this.archiveMagicError
+ },
+ installBlocked() { return this.setup?.uploadAllowed === false },
+ uploadReason() {
+ if (this.installBlocked) return "Installing Spotify needs your local network or the Galaxy tunnel."
+ if (!this.archiveFile) return "Choose the file you downloaded."
+ if (this.archiveError) return this.archiveError
+ if (this.archiveChecking) return "Checking the file…"
+ if (!this.setup?.parked) return "Put the car in Park or use offroad mode to install."
+ if (this.setup.import?.state === "running") return "Wait for the current install to finish."
+ return ""
+ },
+ installState() {
+ const job = this.setup?.import
+ const current = this.installAttempted && job && job.started !== this.installBaseline
+ if (this.uploadProgress || this.installAttempted && this.busy && !current) return "uploading"
+ if (job?.state === "running") return "checking"
+ if (!current) return "choose"
+ if (job.state === "done" && this.setup.release.installed) return "done"
+ return job.state === "failed" ? "failed" : "choose"
+ },
+ checks() { return installChecks(this.setup?.import) },
+ problem() { return importProblem(this.setup?.import) },
+ keyValid() { return /^[A-Za-z0-9._~+/=-]{16,256}$/.test(this.keyValue.trim()) },
+ nameValid() { return /^[^\x00-\x1f\x7f]{1,40}$/.test(this.nameValue.trim()) },
+ outputChoices() {
+ const devices = this.setup?.devices || []
+ return devices.map((device) => ({ value: device.address, label: device.name + (device.connected ? "" : " (Not Connected)") }))
+ },
+ },
+ methods: {
+ switchRow, formatTime,
+ setEnabled(enabled) { return this.feed.post("./api/spotify/enable", { enabled }) },
+ async saveKey() {
+ if (!this.keyValid || this.busy) return
+ const key = this.keyValue.trim()
+ this.keyValue = ""
+ const result = await this.feed.post("./api/spotify/key", { key })
+ if (result) this.keyEditing = false
+ },
+ removeKey() { return this.feed.post("./api/spotify/maintenance", { action: "remove_key" }) },
+ chooseSource(event) { return this.feed.post("./api/spotify/preferences", { source: event.target.value }) },
+ choosePhone(event) { return this.feed.post("./api/spotify/preferences", { phoneAddress: event.target.value }) },
+ chooseOutput(event) {
+ const value = event.target.value
+ if (["test", "android_auto"].includes(value)) return this.feed.post("./api/spotify/preferences", { output: value, outputAddress: "" })
+ if (ADDRESS.test(value)) return this.feed.post("./api/spotify/preferences", { output: "bluetooth", outputAddress: value })
+ },
+ saveName() {
+ if (!this.nameValid || this.nameValue.trim() === this.setup?.preferences.deviceName) return
+ return this.feed.post("./api/spotify/preferences", { deviceName: this.nameValue.trim() })
+ },
+ saveCache(event) { return this.feed.post("./api/spotify/preferences", { cacheMb: Number(event.target.value) }) },
+ clearCache() { return this.feed.post("./api/spotify/maintenance", { action: "clear_cache" }, 20000) },
+ async resetSession() { this.resetOpen = false; return this.feed.post("./api/spotify/maintenance", { action: "reset_session" }, 20000) },
+ async removeAll() { this.removeOpen = false; return this.feed.removeAll() },
+ control(action, args) { return this.feed.control(action, args) },
+ togglePlay() { return this.control(this.playing ? "pause" : "play") },
+ commitVolume(event) {
+ const volume = Math.round(Number(event.target.value))
+ this.volumeDraft = null
+ if (Number.isInteger(volume) && volume >= 0 && volume <= 100) return this.control("volume", { volume })
+ },
+ openInstall() { this.chooseAgain(); Object.assign(this, { installOpen: true, downloadStarted: false }) },
+ closeInstall() { this.installOpen = false },
+ markDownloadStarted() {
+ if (!this.busy && ["choose", "failed"].includes(this.installState)) this.chooseAgain()
+ this.downloadStarted = true
+ },
+ chooseAgain() {
+ this.archiveSelection++
+ Object.assign(this, { installAttempted: false, archiveFile: null, archiveMagicError: "", archiveChecking: false })
+ },
+ async chooseArchive(event) {
+ const file = event.target.files?.[0] || null
+ const selection = ++this.archiveSelection
+ Object.assign(this, { archiveFile: file, archiveMagicError: "", archiveChecking: false })
+ if (!file || archivePreflight(file) || !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.setup?.maxUploadBytes) return
+ this.archiveChecking = true
+ let error = ""
+ try {
+ const bytes = new Uint8Array(await file.slice(0, 2).arrayBuffer())
+ if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) error = "That isn’t the Soloist download. Choose soloist_release_arm64.tar.gz."
+ else if (file.size < 1024 * 1024) error = "This file is too small to be the Soloist download. Download it again."
+ } catch { error = "The file couldn’t be read. Choose it again." }
+ if (selection === this.archiveSelection) Object.assign(this, { archiveMagicError: error, archiveChecking: false })
+ },
+ async upload() {
+ if (this.busy || this.uploadReason) return false
+ Object.assign(this, { installAttempted: true, installBaseline: this.setup.import?.started ?? null })
+ return this.feed.upload(this.archiveFile)
+ },
+ },
+ template: `
+
+
+ {{ error }}
+ {{ error || "Checking Spotify setup… Reconnecting automatically…" }}
+
+
+ Soloist expires 90 days after Spotify builds it, regardless of when you install it.
+
+ {{ release.level === 'expired' ? 'Install a newer build to resume Spotify playback.' : 'Install a newer build before this one expires to keep Spotify playing.' }}
+
+
+
+
+
+ Music Source
+
+
+ Pair your phone in the companion app, or tap Pair phone on the Bluetooth page. Then choose it below and turn on phone audio. On your phone, select the comma as the media audio output. No Spotify install or API key is needed.
+
+
+ {{ statusText }}
+ Music only. Calls and microphone audio are not forwarded.
+
+
+
+
+ Put the car in Park or use offroad mode to change the source.
+
+
+
+
+
+
{{ track?.title || setup.preferences.deviceName }}
+
{{ track.artists }} · {{ track.album }}
+
{{ statusText }} · {{ outputName }}
+
+
+
+
+ {{ formatTime(elapsed) }} / {{ formatTime(track.durationMs) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
1
+
Spotify Soloist
Spotify’s player for devices like your comma. You download it from Spotify, then upload it here.
+
+
+
+
{{ release.label }}
+
+
+
+
+
+
+
Installing Spotify needs your local network or the Galaxy tunnel. Phone audio works over Bluetooth.
+
Installing…
+
Each Soloist build works for 90 days from its build date, not its install date. Before it runs out, this step shows Update; download again the same way.
+
+
+
+ -
+
+
2
+
Your API Key
Spotify gives each person their own key. Creating one needs Spotify Premium.
+
+
+
+ Key saved on this comma
+
+
+
+
+
+
+
+ Create Your API Key
+
+ The key stays on your comma and is never shown again. On your home network Galaxy isn’t encrypted, so save it from a network you trust.
+
+
+
+
+ -
+
+
3
+
Where Music Plays
Through Android Auto, or your car or a speaker over Bluetooth. Alerts keep playing from the comma itself.
+
+
+
+
Put the car in Park or use offroad mode to change this.
+
Music plays through the car while Android Auto is connected, and the car shows it as Android Auto audio. If you switch the car to the radio, playback is paused when the source supports it.
+
No paired devices yet. Pair your car or speaker on the Bluetooth page first.
+
Don’t see it? Pair it on the Bluetooth page. In your car, choose Bluetooth Audio as the source.
+
+
+
+ -
+
+
4
+
Connect Spotify
Turn it on, then pick {{ setup.preferences.deviceName }} in the Spotify app’s device list.
+
+
+
+
{{ statusText }}
+
The first time, your phone has to be on the same Wi‑Fi or hotspot as your comma. After that, Spotify remembers it.
+
+
+
+
+
+
+ Storage and Account
+
+
+
+
+
+
+
+
+
+ Help
+ Why do I download Soloist myself?
+ Spotify asks that each person download Soloist from Spotify and use their own API key, so StarPilot can’t include it. Your comma checks that the file you upload is exactly what Spotify is offering before installing it.
+
+ Spotify doesn’t show my comma
+
+ - Make sure step 4 says Ready to pair.
+ - Put your phone on the same Wi‑Fi or hotspot as your comma. Some hotspots and guest networks block devices from seeing each other.
+ - In Spotify, tap the device icon and look for {{ setup?.preferences.deviceName || 'StarPilot' }}.
+
+
+ Does it use my data plan?
+ Yes, when your comma streams over cellular. Music usually uses about 40–150 MB an hour depending on quality. Songs you’ve played recently come from the cache instead.
+
+ Will music cover up StarPilot’s alerts?
+ Music plays through your car or speaker; alerts keep playing from the comma’s own speaker. Keep the music at a level where you can still hear the comma.
+
+ Can I use it with Android Auto?
+ Yes. In step 3 choose Android Auto, and music plays through the car as Android Auto audio alongside the StarPilot screen. It needs Android Auto to be connected, so if it isn’t, music has nowhere to play.
+
+ More help from Spotify
+ Spotify’s Soloist getting-started guide
+
+
+
+
+ Use a different Spotify account?
+
This signs your comma out of Spotify and clears its cache. Then pick it in the Spotify app on the account you want.
+
+
+
+
+
+
+
+ Remove Spotify?
+
This stops music and deletes Soloist, your API key, your Spotify sign-in and the music cache from this comma. Your Bluetooth devices and Android Auto setup stay as they are.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Soloist is installed
+
{{ release.label }}
+
+
+
+
+
+
{{ setup.release.installed ? 'Install a Newer Soloist' : 'Install Spotify Soloist' }}
+
{{ setup.release.installed ? release.label + '. Your settings and Spotify sign-in stay as they are.' : 'Download it from Spotify, then send it to your comma.' }}
+
+
+ -
+
+
1
+
Download Soloist
+ From Spotify’s Downloads page, to this {{ onPhone ? 'phone' : 'computer' }}
+
+
+
+ Open Spotify Downloads
+
+ - Download the ARMv8/AArch64 Linux build. The file is named {{ setup.archiveName }}.
+ - Leave it as it is. Don’t unzip or open it.
+
+
+
+ -
+
+
2
+
Choose the downloaded file
+ {{ downloadStarted && downloadsHint ? downloadsHint : 'The file you just downloaded' }}
+
+
+
+
Checking the file…
+
+ {{ archiveFile.name }}{{ Math.ceil(archiveFile.size / 1048576) }} MB
+
+
{{ archiveError }}
+
+
+ -
+
+
3
+
InstallSend it to your comma and check it with Spotify
+
+
+
+
{{ uploadReason }}
+
+
+
Sending to your comma… {{ uploadPercent }}%
+
+
+
+ {{ problem }}
+
+
+
{{ error }}
+
+
+
+
+
+
+
+ `,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/startup.js b/openpilot/starpilot/galaxy/web/js/startup.js
index 97975de743..908fabfcf6 100644
--- a/openpilot/starpilot/galaxy/web/js/startup.js
+++ b/openpilot/starpilot/galaxy/web/js/startup.js
@@ -1,5 +1,6 @@
export async function requestJson(url, { fetcher = (...args) => fetch(...args), timeout = 8000,
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id), request = {}, withResponse = false } = {}) {
+ if (globalThis.StarPilotApp?.bluetooth?.()) timeout = Math.max(timeout, 120000)
const controller = new AbortController()
let timer
let rejectCanceled
diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js
index 679c3feeb7..b4cfd6f1ad 100644
--- a/openpilot/starpilot/galaxy/web/js/vasm.js
+++ b/openpilot/starpilot/galaxy/web/js/vasm.js
@@ -5,11 +5,12 @@ import { SettingsFeed, settingsBusy } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
import { LiveCameraPreview } from "./cameras.js"
import { GxNotice } from "./notice.js"
+import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js"
export const VasmPage = {
name: "VasmPage",
- components: { GxDialog, GxState, GalaxySettingRow, GxNotice },
+ components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
setup(props) {
@@ -33,7 +34,7 @@ export const VasmPage = {
"This saved camera size cannot be edited here. Choose a supported size and redraw the regions."
}
} })
- return { state, feed, FORMATS, displaySide }
+ return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection() }
},
mounted() {
this._dragIndex = -1
@@ -165,20 +166,21 @@ export const VasmPage = {
+
Turn the vehicle off to change these settings.
Local saved settings are unavailable in preview.
Reading camera regions and spot-monitoring preferences…
The device could not read camera regions or spot-monitoring preferences. Reconnecting automatically…
{{ state.error }}
- {{ state.cameraError }}
+ Saved settings remain available.
+ {{ state.cameraError }}
The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.
- {{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}
+ {{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}