import { GxDialog } from "./dialog.js" import { GxState } from "./state.js" import { connectionError } from "./polling.js" import { reactive, nextTick } from "../vendor/vue/vue.esm-browser.js" import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js" import { OnroadLayoutPage } from "./onroad-layout.js" import { FavoritesPage } from "./favorites.js" import { SoundPacks } from "./sound-packs.js" import { CloudProviderPage } from "./cloud-provider.js" import { GxNotice } from "./notice.js" import { route } from "./route-state.js" const post = (fetcher, path, body, signal) => fetcher(path, { method: "POST", credentials: "same-origin", cache: "no-store", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) export const SETTINGS_SECTIONS = Object.freeze([ { id: "lateral", label: "Lateral (Steering)", icon: "bi-arrows-move", pages: ["aol", "lane_change", "torque", "lane"] }, { id: "longitudinal", label: "Longitudinal (Speed & Following)", icon: "bi-speedometer2", pages: ["profiles", "conditional", "curve", "slc", "aggressive", "standard", "relaxed", "traffic"] }, { id: "wheel", label: "Wheel Controls", icon: "bi-controller", pages: ["wheel"] }, { id: "visual", label: "Visual (Display & UI)", icon: "bi-eye", pages: ["appearance", "ui_layout", "favorites", "pip"] }, { id: "sounds", label: "Sounds & Alerts", icon: "bi-volume-up", pages: ["sounds"] }, { id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["display", "data"] }, { id: "localization", label: "Localization", icon: "bi-globe", pages: ["localization"] }, { id: "developer", label: "Developer", icon: "bi-code-slash", pages: ["developer"] }, ]) // Crop numeric rows and the inline editor row are controlled from the dedicated // Blind Spot Camera page; they stay writable through the settings API for the // widget/VASM editor but must not surface in the generic toggles list. export const isPipCropRow = (row) => ["Visual camera crop editor", "Camera crop size", "Restore default camera crop"].includes(row.label) || /^Vehicle (left|right) crop/.test(row.label || "") const SECTION_LINKS = { device: [{ label: "Display", page: "display", value: "Adjust screen brightness and display timing" }, { label: "Data Uploads", page: "data", value: "Choose mobile-data uploads and usage-statistics sharing" }], visual: [{ label: "Driving Screen Widgets, Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera and Preview", page: "pip" }], } const activeSettings = new Set() export const settingsBusy = state => ["updating", "saving"].includes(state.status) || !!state.pending || !!state.reviewing export class SettingsFeed { constructor({ publish, unauthorized = () => { }, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer }) this.active = false this.generation = 0 this.request = null this.timer = null this.pollTimer = null this.polling = false this.saving = false this.page = "hub" this.data = null this.pending = null this.error = "" } stop() { activeSettings.delete(this) this.active = false this.generation++ this.request?.abort() if (this.timer !== null) this.cancelTimer(this.timer) if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) this.request = this.timer = this.pollTimer = null this.polling = false this.saving = false this.data = null this.pending = null this.error = "" this.publish({ status: "idle", data: null, pending: null, error: "" }) } start(page = "hub") { this.stop() this.active = true activeSettings.add(this) this.page = page return this.load(page) } async run(operation, { saving = false, background = false } = {}) { if (!this.active) return null const generation = ++this.generation this.request?.abort() if (this.timer !== null) this.cancelTimer(this.timer) if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) this.pollTimer = null const request = new AbortController() this.request = request this.saving = saving this.polling = background let failed = false this.timer = this.later(() => { if (!this.active || generation !== this.generation || this.request !== request) return request.abort() this.generation++ this.request = this.timer = null this.saving = false this.polling = false if (!background) this.data = this.pending = null else this.expireMonitorStatus() this.error = saving ? "Saving timed out. Checking saved values before another change." : "Settings could not connect. Reconnecting automatically…" this.publish({ status: background && this.data ? "ready" : "unavailable", data: this.data, pending: this.pending, error: this.error }) this.scheduleParkedRefresh() }, 4000) try { const response = await operation(request.signal) if (!this.active || generation !== this.generation || request.signal.aborted) return null const accessCode = response.status === 503 ? (await response.clone().json().catch(() => null))?.code : null if (!this.active || generation !== this.generation || request.signal.aborted) return null if (response.status === 401 || ["access_unavailable", "setup_required"].includes(accessCode)) { this.stop() this.unauthorized() return null } if (response.status === 409) { const error = new Error("Saved settings changed. Refresh this page.") error.stale = true throw error } if (!response.ok) throw new Error(response.status === 503 ? "The device could not read these preferences. Reconnecting automatically…" : "The device could not complete the settings request. Check your connection and try again.") const data = await response.json().catch(() => { throw new Error("Galaxy could not load settings. Refresh to reconnect.") }) if (!this.active || generation !== this.generation || request.signal.aborted) return null return data } catch (error) { failed = true if (this.active && generation === this.generation && !request.signal.aborted) { this.pending = null this.expireMonitorStatus() if (error?.stale) this.data = null this.publish({ status: this.data ? "ready" : "unavailable", data: this.data, pending: null, error: this.error = connectionError(error) }) } return null } finally { if (this.request === request) { if (this.timer !== null) this.cancelTimer(this.timer) this.request = this.timer = null this.saving = false this.polling = false if (failed) this.scheduleParkedRefresh() } } } expireMonitorStatus() { if (this.data?.page === "sentry") this.data = { ...this.data, subtitle: "Motion monitor status is unavailable. Reconnecting automatically…" } } scheduleParkedRefresh() { if (!this.active || this.pending || this.request || this.saving || this.pollTimer !== null) return const generation = this.generation this.pollTimer = this.later(() => { this.pollTimer = null if (this.active && this.generation === generation && !this.pending && !this.request && !this.saving) this.load(this.page, { keepData: true, quiet: true }) }, this.error ? 3000 : 1500) } async load(page = this.page, { keepData = page === this.page && this.data !== null, quiet = keepData } = {}) { if (!this.active || this.saving || (quiet && (this.pending || this.request))) return this.page = page this.pending = null if (!keepData) this.data = null if (!quiet) this.publish({ status: keepData ? "saving" : this.error ? "unavailable" : "loading", data: this.data, pending: null, error: this.error }) const operation = this.run((signal) => this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`, { credentials: "same-origin", cache: "no-store", signal }), { background: quiet }) const attempt = this.generation const data = await operation if (data && this.active && this.generation === attempt && this.page === page) { this.data = data this.error = "" this.publish({ status: "ready", data, pending: null, error: "" }) } this.scheduleParkedRefresh() } async preview(row, direction, draft = null) { if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return this.publish({ status: "updating", data: this.data, pending: null, error: "" }) const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview", { view: this.data.view, row, direction, ...(draft === null ? {} : { draft }) }, signal)) if (data) { this.pending = data this.publish({ status: "ready", data: this.data, pending: data, error: "" }) } } async resetDefault(row) { if (!this.active || !this.data?.view || !this.data.rows?.[row]?.resetAvailable || this.pending || (this.request && !this.polling)) return this.publish({ status: "updating", data: this.data, pending: null, error: "" }) const data = await this.run((signal) => post(this.fetcher, "./api/settings/reset-default", { view: this.data.view, row }, signal)) if (data) { this.pending = data await this.confirm() } } async previewValue(row, value) { if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return this.publish({ status: "updating", data: this.data, pending: null, error: "" }) const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview", { view: this.data.view, row, value }, signal)) if (data) { this.pending = data await this.confirm() } } cancel() { this.pending = null if (this.active) this.publish({ status: "ready", data: this.data, pending: null, error: "" }) this.scheduleParkedRefresh() } async confirm() { if (!this.active || !this.pending?.intent) return const intent = this.pending.intent this.pending = null this.publish({ status: "saving", data: this.data, pending: null, error: "" }) const page = this.page const operation = this.run((signal) => post(this.fetcher, "./api/settings/confirm", { intent, confirmed: true, page }, signal), { saving: true }) const attempt = this.generation const result = await operation if (result?.saved === true) { for (const feed of activeSettings) { if (feed !== this && !feed.pending && !feed.saving) feed.load(feed.page, { keepData: true, quiet: true }) } } if (this.active && this.generation === attempt) { if (result?.saved === true && result.page?.page === page && result.page?.view) { this.data = result.page this.error = "" this.publish({ status: "ready", data: this.data, pending: null, error: "" }) this.scheduleParkedRefresh() return } const reload = this.load(page, { keepData: true, quiet: false }) const refreshed = this.generation await reload if (result?.saved !== true && this.active && this.generation === refreshed && this.data) this.publish({ status: "ready", data: this.data, pending: null, error: "Save could not be confirmed. Current saved values are shown; review them before trying again." }) } } } export const SettingsPage = { emits: ["nested"], components: { GxDialog, GxState, GalaxySettingRow, OnroadLayoutPage, FavoritesPage, SoundPacks, CloudProviderPage, GxNotice }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, initialPage: { type: String, default: "hub" }, title: { type: String, default: "Toggles" }, initialSection: { type: String, default: null }, returnTo: { type: Function, default: null } }, setup(props) { const navigation = route.view const navigationKey = `${props.initialPage}:${props.initialSection || ""}` const saved = navigation.settings?.key === navigationKey ? navigation.settings : null const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 }) if (saved) Object.assign(state, { section: saved.section, query: saved.query, searchOpen: saved.searchOpen, parents: saved.parents.slice(), developerOpen: saved.developerOpen }) const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized }) return { state, feed, sections: SETTINGS_SECTIONS, navigation, navigationKey, restoredPage: saved?.page } }, computed: { busy() { return settingsBusy(this.state) }, activeSection() { if (this.state.developerOpen) return this.sections.at(-1) const page = (this.state.data?.page || "hub").split("/")[0] return this.sections.find((section) => page === "hub" ? section.id === this.state.section : section.pages.includes(page)) || this.sections[0] }, atSectionRoot() { if (this.state.developerOpen || (!this.state.data && this.initialPage === "hub" && !this.state.parents?.length)) return true return this.state.data?.page === "hub" || (this.activeSection.pages.length === 1 && this.state.data?.page === this.activeSection.pages[0]) }, visibleRows() { const needle = this.state.query.trim().toLocaleLowerCase() const rows = this.state.data?.rows || [] const inHub = this.initialPage === "hub" && this.state.data?.page === "hub" const section = this.activeSection const entries = inHub && SECTION_LINKS[section.id] ? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: row.value || "Customize " + row.label.toLocaleLowerCase() }, index })) : rows.map((row, index) => ({ row, index })).filter(({ row }) => !inHub || section.pages.includes(row.page)) if (inHub && section.id === "longitudinal") { entries.sort((a, b) => section.pages.indexOf(a.row.page) - section.pages.indexOf(b.row.page)) } return entries.filter(({ row }) => !isPipCropRow(row)).filter(({ row, index }) => { const previous = rows[index - 1] return !(["sounds", "display"].includes(this.state.data?.page) && row.repairValue === "Auto" && previous?.choices?.includes("Auto") && settingControl(previous) === "slider" && row.label === `Use Auto ${previous.label}`) }).filter(({ row }) => !needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle)) }, }, watch: { "state.pending"() { this.flushSoundChoices() }, "state.status"(status, previous) { if (previous === "saving" && status === "ready") this.state.soundChoicesDirty = false else this.flushSoundChoices() }, }, async mounted() { if (this.mode !== "local") return await this.feed.start(this.restoredPage || (this.state.developerOpen ? "developer" : this.initialPage)) await nextTick() if (this.feed.active && this.navigation.scroll) window.scrollTo(this.navigation.scroll.x, this.navigation.scroll.y) }, beforeUnmount() { const { data, section, query, searchOpen, parents, developerOpen } = this.state this.navigation.settings = { key: this.navigationKey, page: data?.page || this.restoredPage || this.initialPage, section, query, searchOpen, parents: parents.slice(), developerOpen, } this.navigation.searchPage = this.initialPage === "hub" ? "" : this.initialPage this.feed.stop() }, methods: { open(page) { if (this.busy) return this.$emit?.("nested") this.state.query = "" this.state.panelDirection = 1 if (page === "ui_layout" || page === "appearance") { this.feed.stop() this.state.layoutPanel = page === "appearance" ? "display" : "widgets" this.state.layoutOpen = true } else if (page === "favorites") { this.feed.stop(); this.state.favoritesOpen = true } else { if (this.state.data?.page && this.state.data.page !== page) (this.state.parents ||= []).push(this.state.data.page) this.feed.load(page) } }, togglePageSearch() { this.state.searchOpen = !this.state.searchOpen if (this.state.searchOpen) this.$nextTick(() => this.$refs.pageSearch?.focus()) else this.state.query = "" }, async openCropEditor() { (await import("./router.js")).navigate("/cameras/pip") }, // The embedded editor switches targets through the routed editor, which keeps the leave guard. async openLayoutTarget(target) { (await import("./router.js")).navigate(target === "projection" ? "/theme_maker/starport" : "/theme_maker") }, rowKey(row, index) { return `${this.state.data.page}:${row.key || row.page || index}` }, closeLayout() { this.state.layoutOpen = false; this.state.section = "visual"; this.feed.start(this.initialPage) }, closeFavorites() { this.state.favoritesOpen = false; this.state.section = "visual"; this.feed.start("hub") }, selectSection(section) { if (this.busy) return if (this.sections) { const from = this.sections.findIndex((item) => item.id === this.state.section) const to = this.sections.findIndex((item) => item.id === section.id) if (from !== -1 && to !== -1) this.state.panelDirection = to < from ? -1 : 1 } this.state.parents = [] this.state.section = section.id this.state.developerOpen = section.id === "developer" if (!this.feed.active) { this.feed.start(section.pages.length === 1 ? section.pages[0] : "hub"); return } this.state.query = "" const page = section.pages.length === 1 ? section.pages[0] : "hub" if (this.state.data?.page !== page) this.feed.load(page, { keepData: false, quiet: false }) }, requestRouteLeave(proceed) { if (this.busy || this.$refs.favoritesEditor?.busy || this.$refs.cloudProvider?.busy) return if (this.state.layoutOpen) this.$refs.layoutEditor.requestLeave(proceed) else proceed() }, navigateBack() { if (this.state.pending) { this.feed.cancel(); return true } if (this.busy) return true if (this.state.layoutOpen) { this.$refs.layoutEditor.requestLeave("back"); return true } if (this.state.favoritesOpen) { if (!this.$refs.favoritesEditor?.busy) this.closeFavorites(); return true } if (this.state.developerOpen) return false const nested = this.initialPage === "hub" ? !this.atSectionRoot : this.state.data?.page !== this.initialPage if (!nested) return false this.back() return true }, back() { if (this.state.developerOpen) return this.state.panelDirection = -1 const page = this.state.data?.page || this.initialPage this.state.section = this.activeSection?.id || this.state.section this.state.query = "" if (page === this.initialPage && this.returnTo) { this.returnTo(); return } const parent = this.state.parents?.pop() if (parent) { this.feed.load(parent); return } if (this.initialPage !== "hub") { this.feed.load(this.initialPage); return } this.feed.load(page.includes("/") ? page.split("/")[0] : ["aggressive", "standard", "relaxed"].includes(page) ? "profiles" : "hub") }, refreshSoundChoices() { this.state.soundChoicesDirty = true this.flushSoundChoices() }, flushSoundChoices() { if (!this.state.soundChoicesDirty || this.state.data?.page !== "sounds" || this.feed.saving || this.state.pending) return this.state.soundChoicesDirty = false this.feed.load("sounds", { keepData: true }) }, }, template: `

{{ title }}

Choose developer driving options and cloud services.

Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.

Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.

Adjust brightness and screen timing when custom display settings are on.

Configure lane-change assistance, signal timing, steering pace and following gap.

Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.

Adjust acceleration, braking and following for each driving personality.

Tune low-speed following and jerk, blending toward Relaxed at higher speeds.

Keep steering assistance active independently of cruise control.

Tune how StarPilot applies steering torque.

Choose lane-centering behavior and alerts.

Set how the vehicle slows for curves ahead.

Control how speed limits change your set speed.

Vision speed limits guide

Assign what the steering-wheel controls do.

Choose what uploads when the vehicle uses mobile data.

Adjust acceleration, braking and following for this driving style.

{{ state.data?.subtitle || "Checking motion monitor…" }}

Local settings are unavailable in preview.
`, }