mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 11:33:47 +08:00
Galaxy: Add companion, Spotify and phone-aware web controls
Add companion installation and updates, Spotify setup and playback, destination sharing, and Android Auto controls. Adapt Galaxy pages to phone transports and device type, improve Bluetooth pairing feedback, and cover the new web flows.
This commit is contained in:
@@ -18,6 +18,13 @@ assert.equal(validPairInput({ kind: "passkey" }, "123456"), true)
|
||||
assert.equal(validPairInput({ kind: "passkey" }, "1234567"), false)
|
||||
assert.equal(validPairInput({ kind: "confirmation" }, ""), true)
|
||||
assert.match(BluetoothPage.template, /!canAllowPair/)
|
||||
// A phone running the StarPilot app is connected but not bonded: it lists under App Pairings, not Saved or Nearby devices.
|
||||
const appPhone = { address: "11:22:33:44:55:66", name: "iPhone", paired: false, connected: true, trusted: false }
|
||||
const listed = { status: { ...status, devices: [...status.devices, appPhone] } }
|
||||
assert.deepEqual(BluetoothPage.computed.saved.call(listed).map((device) => device.name), ["Speaker"])
|
||||
assert.deepEqual(BluetoothPage.computed.appLinks.call(listed).map((device) => device.name), ["iPhone"])
|
||||
assert.deepEqual(BluetoothPage.computed.nearby.call(listed), [])
|
||||
assert.match(BluetoothPage.template, /App Pairings/)
|
||||
|
||||
const calls = [], states = [], timers = []
|
||||
const feed = new BluetoothFeed({ publish: (state) => states.push(state),
|
||||
@@ -93,3 +100,14 @@ for (const [code, words] of [["busy", /another operation/], ["park_required", /P
|
||||
errorFeed.stop()
|
||||
}
|
||||
assert.equal(validBluetoothStatus({ ...status, errorCode: "service_unavailable" }), true)
|
||||
|
||||
// A window opened for a phone has no address until the first phone asks, and only phone windows may omit it.
|
||||
assert.equal(validBluetoothStatus({ ...status, adapterAddress: "AA:BB:CC:DD:EE:FF", adapterName: "comma",
|
||||
pairing: { address: "", state: "pairing", prompt: null, incoming: true, name: "" } }), true)
|
||||
assert.equal(validBluetoothStatus({ ...status,
|
||||
pairing: { address: "11:22:33:44:55:66", state: "pairing", prompt, incoming: true, name: "Pixel" } }), true)
|
||||
assert.equal(validBluetoothStatus({ ...status, pairing: { address: "", state: "pairing", prompt: null } }), false)
|
||||
assert.equal(validBluetoothStatus({ ...status, pairing: { address: "not-an-address", state: "pairing", prompt: null, incoming: true, name: "" } }), false)
|
||||
assert.equal(validBluetoothStatus({ ...status, adapterName: 5 }), false)
|
||||
assert.match(BluetoothPage.template, /Pair phone/)
|
||||
assert.match(BluetoothPage.template, /action\('accept_pair'\)/)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRenderer, nextTick } from '../web/vendor/vue/vue.esm-browser.js'
|
||||
import { BluetoothNotice, bluetoothConnection } from '../web/js/bluetooth-notice.js'
|
||||
import { LocalHistoryFeed, LocalRecordingsPage } from '../web/js/record-history.js'
|
||||
import { CamerasPage, CameraSnapshotFeed, LiveCameraPreview } from '../web/js/cameras.js'
|
||||
import { PipPage } from '../web/js/pip.js'
|
||||
import { VasmPage } from '../web/js/vasm.js'
|
||||
import { NavigationPage } from '../web/js/navigation.js'
|
||||
import { OfflineRoadMapsPanel } from '../web/js/offline-road-maps.js'
|
||||
import { AndroidAutoPage } from '../web/js/android-auto.js'
|
||||
import { GalaxyCompanion } from '../web/js/companion.js'
|
||||
|
||||
globalThis.window = {}
|
||||
assert.equal(bluetoothConnection(), false)
|
||||
let connections = 0, requests = 0, timers = 0
|
||||
window.StarPilotApp = { bluetooth: () => true, connectionOptions: () => connections++ }
|
||||
assert.equal(bluetoothConnection(), true)
|
||||
globalThis.document = { hidden: false, activeElement: null, addEventListener() {}, removeEventListener() {} }
|
||||
globalThis.fetch = () => { requests++; throw new Error('Blocked feature attempted a request') }
|
||||
|
||||
const hostNode = (type, text = '') => ({ type, text, props: {}, children: [], parent: null, addEventListener() {}, removeEventListener() {} })
|
||||
const renderer = createRenderer({
|
||||
createElement: type => hostNode(type), createText: text => hostNode('text', text), createComment: text => hostNode('comment', text),
|
||||
setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text; item.children = [] },
|
||||
patchProp: (item, key, before, after) => { item.props[key] = after },
|
||||
insert: (item, parent, anchor = null) => {
|
||||
if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1)
|
||||
item.parent = parent
|
||||
const index = parent.children.indexOf(anchor)
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, item)
|
||||
},
|
||||
remove: item => { const index = item.parent?.children.indexOf(item); if (index >= 0) item.parent.children.splice(index, 1) },
|
||||
parentNode: item => item.parent, nextSibling: item => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null,
|
||||
})
|
||||
const all = item => [item, ...item.children.flatMap(all)]
|
||||
const text = item => [item.text, ...item.children.map(text)].join(' ')
|
||||
function mount(component, props = {}, overrides = {}) {
|
||||
const host = hostNode('host')
|
||||
const app = renderer.createApp({ ...component, ...overrides }, { mode: 'local', unauthorized() {}, go() {}, ...props })
|
||||
app.config.compilerOptions.decodeEntities = raw => raw.replaceAll('&', '&')
|
||||
const vm = app.mount(host)
|
||||
return { host, vm, unmount: () => app.unmount() }
|
||||
}
|
||||
function notice(host, feature) {
|
||||
const notices = all(host).filter(item => item.props.class?.includes('gx-alert--bluetooth'))
|
||||
const found = notices.find(item => text(item).includes(feature))
|
||||
assert.ok(found, feature)
|
||||
assert.equal(found.props.role, 'status')
|
||||
assert.ok(found.props.class.includes('gx-alert--info'))
|
||||
assert.match(text(found), /Not available over Bluetooth.*Wi-Fi or LAN/s)
|
||||
assert.ok(!all(host).some(item => item.props.class?.includes('gx-alert--danger')), 'Expected limitation is never a red error')
|
||||
return found
|
||||
}
|
||||
|
||||
for (const [component, feature] of [[LocalRecordingsPage, 'Local recordings and replay'], [CamerasPage, 'Camera snapshots']]) {
|
||||
const view = mount(component)
|
||||
try {
|
||||
const info = notice(view.host, feature)
|
||||
all(info).find(item => item.type === 'button').props.onClick()
|
||||
assert.ok(!all(view.host).some(item => item.type === 'img' || item.type === 'video'))
|
||||
} finally { view.unmount() }
|
||||
}
|
||||
assert.equal(connections, 2)
|
||||
assert.equal(requests, 0)
|
||||
|
||||
const history = new LocalHistoryFeed({ publish() {}, later: () => { timers++; return 1 }, cancelTimer() {} })
|
||||
history.start()
|
||||
assert.equal(history.active, false)
|
||||
const camera = new CameraSnapshotFeed({ publish() {}, later: () => { timers++; return 1 }, cancelTimer() {} })
|
||||
await camera.capture('cabin')
|
||||
const preview = new LiveCameraPreview({ publish() {}, redraw() {}, enabled: () => true,
|
||||
later: () => { timers++; return 1 }, cancelTimer() {} })
|
||||
preview.start(); preview.refresh(); preview.stop()
|
||||
assert.equal(timers, 0)
|
||||
assert.equal(requests, 0, 'Neither initial loads nor retries send unsupported recording/camera requests')
|
||||
|
||||
// Render the real editor templates without starting their supported settings feeds.
|
||||
const presentation = { created() {}, mounted() {}, updated() {}, beforeUnmount() {} }
|
||||
for (const component of [PipPage, VasmPage]) {
|
||||
const view = mount(component, {}, presentation)
|
||||
try {
|
||||
notice(view.host, 'Cabin camera preview')
|
||||
const refresh = all(view.host).find(item => item.type === 'button' && text(item).includes('Take new snapshot'))
|
||||
assert.equal(refresh.props.disabled, true)
|
||||
assert.match(text(view.host), /Saved settings remain available/)
|
||||
} finally { view.unmount() }
|
||||
}
|
||||
|
||||
let mapMounts = 0
|
||||
const navigation = mount(NavigationPage, {}, { ...presentation,
|
||||
components: { ...NavigationPage.components, NavigationMap: { mounted() { mapMounts++ }, template: '<canvas />' } } })
|
||||
try {
|
||||
notice(navigation.host, 'Map preview')
|
||||
assert.equal(mapMounts, 0)
|
||||
assert.doesNotMatch(text(navigation.host), /Set up navigation to show the map/)
|
||||
navigation.vm.error = 'Server rejected the destination'
|
||||
await nextTick()
|
||||
assert.ok(all(navigation.host).some(item => item.props.class?.includes('gx-alert--danger')), 'Real navigation errors stay red')
|
||||
} finally { navigation.unmount() }
|
||||
|
||||
OfflineRoadMapsPanel.methods.ensureMap.call({ bluetooth: true, $refs: { canvas: {} }, hasKey: true })
|
||||
let uploaded = 0
|
||||
const aa = { bluetooth: true, packageFile: {}, busy: false, feed: { upload() { uploaded++ } } }
|
||||
aa.uploadReason = AndroidAutoPage.computed.uploadReason.call(aa)
|
||||
assert.match(aa.uploadReason, /not available over Bluetooth/)
|
||||
await AndroidAutoPage.methods.upload.call(aa)
|
||||
assert.equal(uploaded, 0)
|
||||
await GalaxyCompanion.methods.toggle.call({ bluetooth: true })
|
||||
assert.equal(requests, 0)
|
||||
|
||||
window.StarPilotApp.bluetooth = () => false
|
||||
assert.equal(bluetoothConnection(), false)
|
||||
console.log('Bluetooth limits: blue notices, connection action, no blocked requests, supported settings and real errors verified')
|
||||
@@ -115,3 +115,11 @@ test('timed-out background polling preserves status and resumes even when fetch
|
||||
assert.equal(feed.status.devices[0].address, car.address);
|
||||
assert(states.every((s) => s.busy === false)); feed.stop();
|
||||
});
|
||||
|
||||
test('hiding the page keeps a phone pairing window open so the phone can find the comma', async () => {
|
||||
const calls = [];
|
||||
const {feed} = harness(async (url, options) => {calls.push(options); return response(status([car],
|
||||
{discovering: false, pairing: {address: '', state: 'pairing', prompt: null, incoming: true, name: ''}}))});
|
||||
await feed.start(); feed.stop(true);
|
||||
assert(!calls.some((options) => options?.body && JSON.parse(options.body).operation === 'cancel_pair'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { compile } from '../web/vendor/vue/vue.esm-browser.js'
|
||||
import { GalaxyCompanion, validCompanion } from '../web/js/companion.js'
|
||||
globalThis.window = {}
|
||||
const value = { protocol: 1, enabled: false, connected: false, active: false, appAvailable: true, updateAvailable: false,
|
||||
bytes: 0, shippedVersion: '1.0.0', state: 'disabled' }
|
||||
assert.equal(validCompanion(value), true)
|
||||
assert.equal(validCompanion({ ...value, bytes: -1 }), false)
|
||||
compile(GalaxyCompanion.template, { decodeEntities: v => v })
|
||||
const vm = { ...GalaxyCompanion.data(), mode: 'sample', unauthorized() {}, $emit() {} }
|
||||
for (const [key, fn] of Object.entries(GalaxyCompanion.methods)) vm[key] = fn.bind(vm)
|
||||
let requests = 0, resolve
|
||||
globalThis.fetch = () => { requests++; return new Promise(done => { resolve = done }) }
|
||||
vm.start()
|
||||
assert.equal(requests, 0)
|
||||
vm.mode = 'local'; vm.alive = true
|
||||
const reading = vm.refresh()
|
||||
vm.stop()
|
||||
resolve({ ok: true, json: async () => value })
|
||||
await reading
|
||||
assert.equal(vm.status, null)
|
||||
let auth = 0
|
||||
vm.unauthorized = () => auth++
|
||||
globalThis.fetch = async () => ({ ok: false, status: 401, json: async () => ({ error: 'Sign in' }) })
|
||||
vm.alive = true
|
||||
await vm.refresh()
|
||||
assert.equal(auth, 1)
|
||||
assert.equal(vm.alive, false)
|
||||
console.log('Companion UI: schema, template, preview exclusion, late response and authentication passed')
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRenderer, nextTick } from '../web/vendor/vue/vue.esm-browser.js'
|
||||
import { GalaxyPage } from '../web/js/galaxy.js'
|
||||
import { newerCompanionVersion } from '../web/js/companion.js'
|
||||
|
||||
assert.equal(newerCompanionVersion('1.1.5', '1.1.4'), true)
|
||||
assert.equal(newerCompanionVersion('1.1.10', '1.1.9'), true)
|
||||
assert.equal(newerCompanionVersion('1.2', '1.1.99'), true)
|
||||
for (const [available, installed] of [['1.1.5', '1.1.5'], ['1.1.4', '1.1.5'], ['1.1', '1.1.0'],
|
||||
['', '1.1.4'], ['1.1.5', ''], ['unknown', '1.1.4'], ['1.1.5', null]]) {
|
||||
assert.equal(newerCompanionVersion(available, installed), false)
|
||||
}
|
||||
|
||||
const storage = new Map()
|
||||
globalThis.localStorage = { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }
|
||||
globalThis.window = { StarPilotApp: { version: () => '1.1.4', uplinkState: () => '{}' } }
|
||||
globalThis.document = { activeElement: null }
|
||||
const status = { protocol: 1, enabled: false, connected: false, active: false, appAvailable: true, updateAvailable: false,
|
||||
bytes: 0, shippedVersion: '1.1.5', version: null, state: 'disabled' }
|
||||
globalThis.fetch = async url => ({ ok: true, json: async () => url.includes('/api/companion/status') ? status : { paired: false } })
|
||||
|
||||
const node = (type, text = '') => ({ type, text, props: {}, children: [], parent: null,
|
||||
addEventListener() {}, removeEventListener() {} })
|
||||
const renderer = createRenderer({
|
||||
createElement: type => node(type), createText: text => node('text', text), createComment: text => node('comment', text),
|
||||
setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text; item.children = [] },
|
||||
patchProp: (item, key, before, after) => { item.props[key] = after },
|
||||
insert: (item, parent, anchor = null) => {
|
||||
if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1)
|
||||
item.parent = parent
|
||||
const index = parent.children.indexOf(anchor)
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, item)
|
||||
},
|
||||
remove: item => { const index = item.parent?.children.indexOf(item); if (index >= 0) item.parent.children.splice(index, 1) },
|
||||
parentNode: item => item.parent,
|
||||
nextSibling: item => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null,
|
||||
})
|
||||
const all = item => [item, ...item.children.flatMap(all)]
|
||||
const text = item => [item.text, ...item.children.map(text)].join(' ')
|
||||
const banner = host => all(host).find(item => item.type === 'div' && text(item).includes('Android app update available') && item.props.role === 'status')
|
||||
async function mount() {
|
||||
const host = node('host')
|
||||
const app = renderer.createApp({ ...GalaxyPage,
|
||||
// The real companion child still polls and emits metadata to the real page.
|
||||
components: { ...GalaxyPage.components, GalaxyHotspot: { template: '<div />' }, InstallApp: { props: ['updateAvailable'], template: '<div class="install-state">{{ updateAvailable }}</div>' } },
|
||||
}, { mode: 'local', unauthorized() { assert.fail('Unexpected authentication failure') } })
|
||||
app.config.compilerOptions.decodeEntities = raw => raw.replaceAll('&', '&')
|
||||
const vm = app.mount(host)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await nextTick()
|
||||
return { host, app, vm }
|
||||
}
|
||||
|
||||
let mounted = await mount()
|
||||
try {
|
||||
const { host, vm } = mounted
|
||||
const notice = banner(host)
|
||||
assert.ok(notice, 'A native app without an internet-sharing lease still gets an update notice')
|
||||
assert.match(text(notice), /Version 1.1.5 is available.*Installed: 1.1.4/s)
|
||||
assert.ok(all(notice).some(item => item.type === 'a' && item.props.href === './app.apk' && text(item).includes('Update app')))
|
||||
assert.ok(all(host).some(item => item.type === 'div' && item.props.class === 'install-state' && text(item).includes('true')),
|
||||
'The main install action is told to render as an update')
|
||||
const root = host.children.find(item => item.type === 'div')
|
||||
assert.ok(root.children.indexOf(notice) < root.children.findIndex(item => item.type === 'ol'), 'Notice precedes the setup steps')
|
||||
const install = all(host).find(item => item.type === 'li' && text(item).includes('Install Galaxy'))
|
||||
assert.match(text(install), /Android offline app version:.*1.1.5.*Installed.*1.1.4/s)
|
||||
const dismiss = all(notice).find(item => item.type === 'button' && item.props['aria-label'] === 'Dismiss app update notice')
|
||||
dismiss.props.onClick()
|
||||
await nextTick()
|
||||
assert.equal(banner(host), undefined)
|
||||
vm.companionStatus = { ...status }
|
||||
await nextTick()
|
||||
assert.equal(banner(host), undefined, 'Polling does not undo dismissal')
|
||||
} finally { mounted.app.unmount() }
|
||||
|
||||
let nativeInstalls = 0
|
||||
window.StarPilotApp.installUpdate = () => nativeInstalls++
|
||||
mounted = await mount()
|
||||
try {
|
||||
assert.equal(banner(mounted.host), undefined, 'Dismissal survives leaving and returning to the page')
|
||||
mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.6' }
|
||||
await nextTick()
|
||||
const nativeNotice = banner(mounted.host)
|
||||
assert.ok(nativeNotice, 'A later available release gets a new notice')
|
||||
const nativeUpdate = all(nativeNotice).find(item => item.type === 'button' && text(item).includes('Update app'))
|
||||
assert.ok(nativeUpdate, 'A current native app uses the in-app installer')
|
||||
nativeUpdate.props.onClick()
|
||||
assert.equal(nativeInstalls, 1)
|
||||
mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.3', updateAvailable: true }
|
||||
await nextTick()
|
||||
assert.equal(banner(mounted.host), undefined, 'A server mismatch flag cannot suggest downgrading this phone')
|
||||
mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.6', appAvailable: false }
|
||||
await nextTick()
|
||||
assert.equal(banner(mounted.host), undefined, 'Missing APK never advertises a dead update link')
|
||||
mounted.vm.companionStatus = { ...status, shippedVersion: '1.1.6' }
|
||||
localStorage.setItem = () => { throw new Error('Storage unavailable') }
|
||||
mounted.vm.dismissAppUpdate()
|
||||
await nextTick()
|
||||
assert.equal(banner(mounted.host), undefined, 'Dismiss still works when persistent storage is unavailable')
|
||||
} finally { mounted.app.unmount() }
|
||||
|
||||
delete window.StarPilotApp
|
||||
storage.clear()
|
||||
mounted = await mount()
|
||||
try {
|
||||
assert.equal(banner(mounted.host), undefined, 'A browser cannot infer an installed version without a connected phone')
|
||||
const install = all(mounted.host).find(item => item.type === 'li' && text(item).includes('Install Galaxy'))
|
||||
assert.match(text(install), /Android offline app version:.*1.1.5/s)
|
||||
assert.doesNotMatch(text(install), /Installed:/)
|
||||
} finally { mounted.app.unmount() }
|
||||
|
||||
// iPhone users never mount or poll the Android companion section.
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'iPhone' } })
|
||||
let companionMounts = 0
|
||||
const iosHost = node('host')
|
||||
const iosApp = renderer.createApp({ ...GalaxyPage, components: { ...GalaxyPage.components,
|
||||
GalaxyHotspot: { template: '<div />' }, InstallApp: { template: '<div class="web-install">Install as web app</div>' },
|
||||
GalaxyCompanion: { mounted() { companionMounts++ }, template: '<div>Android companion</div>' },
|
||||
} }, { mode: 'local', unauthorized() { assert.fail('Unexpected authentication failure') } })
|
||||
iosApp.config.compilerOptions.decodeEntities = raw => raw.replaceAll('&', '&')
|
||||
const iosVm = iosApp.mount(iosHost)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await nextTick()
|
||||
assert.equal(iosVm.ios, true)
|
||||
assert.equal(companionMounts, 0)
|
||||
assert.doesNotMatch(text(iosHost), /Android companion/)
|
||||
assert.match(text(iosHost), /Install as web app/)
|
||||
iosApp.unmount()
|
||||
|
||||
console.log('Companion install version, in-app update, browser fallback, version ordering and persistent dismissal passed')
|
||||
@@ -13,7 +13,10 @@ assert.ok(Home.template.includes('aria-hidden="true"><svg'))
|
||||
assert.ok(GalaxyPage.template.includes('>Galaxy Access & Install</h2>'))
|
||||
assert.ok(!GalaxyPage.template.includes('Galaxy & App Install'))
|
||||
assert.ok(GalaxyPage.template.includes('Your remote link needs Internet and an active tunnel.'))
|
||||
assert.ok(GalaxyPage.template.includes('Download Android APK'))
|
||||
assert.ok(GalaxyPage.template.includes('Android offline app version:'))
|
||||
assert.ok(GalaxyPage.template.includes('<GalaxyHotspot'))
|
||||
assert.ok(GalaxyPage.template.includes('<GalaxyCompanion v-if="!ios"'), 'The Android companion section is not mounted on iOS')
|
||||
assert.ok(GalaxyPage.template.indexOf('<h3>Remote pairing</h3>') < GalaxyPage.template.indexOf('<InstallApp'))
|
||||
assert.ok(GalaxyPage.template.includes('class="gx-aa__steps"'))
|
||||
assert.ok(GalaxyPage.template.includes('<InstallApp :url="url"'))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { installHelp, InstallApp, rememberInstallPrompt } from '../web/js/install-app.js'
|
||||
import { androidInstallHelp, installHelp, InstallApp, rememberInstallPrompt, isIosDevice, isAndroidDevice } from '../web/js/install-app.js'
|
||||
assert.match(installHelp({ secure: true, agent: 'iPhone' }), /Share.*Add to Home Screen/)
|
||||
assert.match(installHelp({ secure: false, agent: 'Android' }), /https:\/\/galaxy.firestar.link/)
|
||||
assert.match(installHelp({ secure: false, agent: 'Android' }), /web app.*https:\/\/galaxy\.firestar\.link/)
|
||||
assert.match(installHelp({ secure: true, agent: 'Android' }), /Install app/)
|
||||
assert.match(androidInstallHelp(), /download the Android app.*locally|locally.*download the Android app/i)
|
||||
assert.match(installHelp({ secure: true, agent: 'Chrome' }), /Install app/)
|
||||
const events = new Map()
|
||||
let modeListener
|
||||
@@ -15,11 +17,11 @@ assert.equal(state.installed, false)
|
||||
let prevented = 0, prompted = 0
|
||||
events.get('beforeinstallprompt')({ preventDefault: () => prevented++, prompt: async () => prompted++, userChoice: Promise.resolve({ outcome: 'dismissed' }) })
|
||||
assert.equal(prevented, 1)
|
||||
await InstallApp.methods.install.call(state)
|
||||
await InstallApp.methods.installWeb.call(state)
|
||||
assert.equal(prompted, 1)
|
||||
assert.equal(state.prompt, null)
|
||||
assert.equal(state.installed, false)
|
||||
await InstallApp.methods.install.call(state)
|
||||
await InstallApp.methods.installWeb.call(state)
|
||||
assert.equal(state.message, '')
|
||||
assert.match(InstallApp.template, /v-else-if="url" class="gx-btn" :href="url"/)
|
||||
window.location = { hostname: 'galaxy.firestar.link' }
|
||||
@@ -38,6 +40,48 @@ rememberInstallPrompt(early)
|
||||
const late = {...InstallApp.data()}
|
||||
InstallApp.mounted.call(late)
|
||||
assert.equal(late.prompt, early)
|
||||
await InstallApp.methods.install.call(late)
|
||||
await InstallApp.methods.installWeb.call(late)
|
||||
assert.equal(prompted, 2)
|
||||
InstallApp.beforeUnmount.call(late)
|
||||
|
||||
// Android offers the local APK; tunnel pages explain how to connect locally.
|
||||
navigator.userAgent = 'Android'
|
||||
let apk
|
||||
globalThis.location = { protocol: 'http:', assign: value => { apk = value } }
|
||||
let nativeUpdates = 0
|
||||
window.StarPilotApp = { installUpdate: () => nativeUpdates++ }
|
||||
const nativeUpdate = { ...InstallApp.data(), updateAvailable: true }
|
||||
InstallApp.methods.installAndroid.call(nativeUpdate)
|
||||
assert.equal(nativeUpdates, 1)
|
||||
assert.equal(apk, undefined, 'Native updates never hand the APK to a browser')
|
||||
delete window.StarPilotApp
|
||||
const android = { ...InstallApp.data() }
|
||||
InstallApp.methods.installAndroid.call(android)
|
||||
assert.equal(apk, './app.apk')
|
||||
assert.match(InstallApp.template, /Install web app/)
|
||||
assert.match(InstallApp.template, /Download Android APK/)
|
||||
assert.match(InstallApp.template, /Update offline app \(Android\)/)
|
||||
assert.match(InstallApp.template, /android && !ios/)
|
||||
location.protocol = 'https:'
|
||||
InstallApp.methods.installAndroid.call(android)
|
||||
assert.equal(android.help, true)
|
||||
assert.match(android.message, /Android app.*locally|locally.*Android app/i)
|
||||
|
||||
// Choosing the web app on Android always keeps web-install guidance.
|
||||
const webHelp = InstallApp.methods.installHelp.call(android)
|
||||
assert.match(webHelp, /Install app/)
|
||||
assert.doesNotMatch(webHelp, /download the Android app/i)
|
||||
|
||||
// iOS never sees the Android offline install; the web app install remains.
|
||||
assert.equal(isIosDevice('iPhone'), true)
|
||||
assert.equal(isIosDevice('iPad'), true)
|
||||
assert.equal(isIosDevice('Android'), false)
|
||||
assert.equal(isAndroidDevice('Android'), true)
|
||||
assert.equal(isAndroidDevice('iPhone'), false)
|
||||
navigator.userAgent = 'iPhone'
|
||||
const ios = { ...InstallApp.data() }
|
||||
assert.equal(ios.android, false)
|
||||
assert.equal(ios.ios, true)
|
||||
assert.match(InstallApp.template, /<a v-if="ios"[^>]+href="https:\/\/apps\.apple\.com\/ca\/app\/starpilot-bt\/id6821039119"/)
|
||||
assert.match(InstallApp.methods.installHelp.call(ios), /Share.*Add to Home Screen/)
|
||||
assert.match(InstallApp.template, /v-if="android && !ios/)
|
||||
|
||||
@@ -13,6 +13,11 @@ for (const key of ['avoidTolls', 'avoidHighways', 'avoidFerries']) {
|
||||
for (const value of ['true', 1, null, []]) assert(!validNavigation({ ...snapshot(), [key]: value }))
|
||||
}
|
||||
assert(!validNavigation({ ...snapshot(), route: [{ latitude: 90, longitude: Infinity }] }))
|
||||
for (const value of ['backup', 'comma', 'phone']) assert(validNavigation({ ...snapshot(), gpsSource: value }))
|
||||
assert(!validNavigation({ ...snapshot(), gpsSource: 'tunnel' }))
|
||||
const located = { latitude: 1, longitude: 2, validForMs: 1000 }
|
||||
assert(validNavigation({ ...snapshot(), location: { ...located, source: 'phone' } }))
|
||||
assert(!validNavigation({ ...snapshot(), location: { ...located, source: 'satellite' } }))
|
||||
assert(!validNavigation({ ...snapshot(), destination: { ...place, latitude: 120 } }))
|
||||
assert(!validNavigation({ ...snapshot(), instruction: { text: "Turn left" } }))
|
||||
for (const network of ['online', 'offline', 'unknown']) assert(validNavigation({ ...snapshot(), network }))
|
||||
@@ -249,6 +254,7 @@ console.log("Local HTTP search UUID fallback passed")
|
||||
assert.deepEqual(actions[1], ['configure', { patch: { token: 'pk.replacement' } }])
|
||||
assert.ok(NavigationPage.template.includes("selectTab('setup')"))
|
||||
assert.ok(NavigationPage.template.includes('Save key'))
|
||||
assert.ok(NavigationPage.template.includes('<NavigationMap v-if="ready"'))
|
||||
assert.ok(NavigationPage.template.includes('<BluetoothNotice v-if="bluetooth" feature="Map preview"'))
|
||||
assert.ok(NavigationPage.template.includes('<NavigationMap v-else-if="ready"'))
|
||||
}
|
||||
await import("./test_navigation_map.mjs")
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
||||
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
|
||||
import { snapshot } from "./test_onroad_layout.mjs"
|
||||
import { editorSnapshot, projectionPayload } from "../web/js/projection-layout.js"
|
||||
import { OnroadLayoutFeed, OnroadLayoutPage, validSnapshot, validDocument } from "../web/js/onroad-layout.js"
|
||||
import { LayoutWidgetPreview, OnroadLayoutFeed, OnroadLayoutPage, validSnapshot, validDocument } from "../web/js/onroad-layout.js"
|
||||
|
||||
const copy = (v) => JSON.parse(JSON.stringify(v))
|
||||
const native = snapshot()
|
||||
@@ -20,7 +20,9 @@ metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96,
|
||||
opacity: { min: 25, max: 100, default: 75 }, backgroundToggle: true,
|
||||
note: "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.",
|
||||
default: { x: 30, y: 954, enabled: true, opacity: 75, background: false } }
|
||||
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "car_exit"]
|
||||
metadata.widgets.jetlink_status = { label: "Jetlink status", kind: "jetlink_status", width: 420, height: 84, colors: {},
|
||||
opacity: { min: 0, max: 100, default: 85 }, available: true, default: { x: 30, y: 650, enabled: false, opacity: 85 } }
|
||||
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "jetlink_status", "car_exit"]
|
||||
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
|
||||
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
|
||||
const doc = { version: 4, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", canvas: { width: 2880, height: 1080 }, widgets }
|
||||
@@ -32,6 +34,18 @@ const data = editorSnapshot(raw)
|
||||
assert.equal(validSnapshot(data), true)
|
||||
assert.equal(validDocument(data.document, data.metadata), true)
|
||||
assert.equal(data.document.speedAlign, "center") // Legacy preferences still load after removing the editor control.
|
||||
assert.equal(data.document.layouts.large.jetlink_status.enabled, false)
|
||||
const jetlinkVm = { profile: data.metadata.profiles.large, layout: copy(data.document.layouts.large) }
|
||||
const widgetRows = () => OnroadLayoutPage.computed.widgets.call(jetlinkVm)
|
||||
assert.ok(widgetRows().some(widget => widget.id === 'jetlink_status'))
|
||||
jetlinkVm.profile.widgets.jetlink_status.available = false
|
||||
assert.ok(!widgetRows().some(widget => widget.id === 'jetlink_status'))
|
||||
jetlinkVm.layout.jetlink_status.enabled = true
|
||||
assert.ok(widgetRows().some(widget => widget.id === 'jetlink_status'), 'turning Jetlink off retains the placed widget')
|
||||
assert.equal(validDocument({...data.document, layouts: {large: jetlinkVm.layout}}, data.metadata), true)
|
||||
jetlinkVm.profile.widgets.jetlink_status.available = true
|
||||
assert.match(LayoutWidgetPreview.template, /24\.6/)
|
||||
compile(LayoutWidgetPreview.template, { decodeEntities: value => value.replaceAll("&", "&") })
|
||||
assert.equal(data.document.layouts.large.car_exit.opacity, 75)
|
||||
assert.equal(data.document.layouts.large.car_exit.background, false)
|
||||
const resizedMonitor = copy(data.document)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseSharedDestination as parse } from '../web/js/share-destination.js'
|
||||
for (const text of ['https://maps.google.com/@12.3,-45.6,15z', 'https://maps.google.com/?q=12.3,-45.6',
|
||||
'geo:12.3,-45.6(Home)', 'google.navigation:q=12.3,-45.6', 'https://maps.google.com/?daddr=12.3,-45.6',
|
||||
'https://maps.google.com/@1,2,15z/data=!3d12.3!4d-45.6']) {
|
||||
test(text, () => { const { destination } = parse(text); assert.equal(destination.latitude, 12.3); assert.equal(destination.longitude, -45.6) })
|
||||
}
|
||||
test('address search', () => assert.equal(parse('geo:0,0?q=10+Main+Street').query, '10 Main Street'))
|
||||
test('invalid coordinate does not route', () => assert.equal(parse('geo:123,456').destination, undefined))
|
||||
test('encoded coordinates', () => assert.equal(parse('https://maps.google.com/?q=12.3%2C-45.6').destination.longitude, -45.6))
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { SpotifyFeed, SpotifyPage, archivePreflight, importProblem, installChecks, releaseInfo, serviceLabel,
|
||||
positionNow, formatTime, DOWNLOAD_PAGE } from "../web/js/spotify.js"
|
||||
|
||||
const setup = {
|
||||
enabled: true, parked: true, keyPresent: true, audioReady: true,
|
||||
release: { installed: true, version: "1.3.9.32", expires: "2027-01-14T00:00:00+00:00", daysLeft: 80, level: "ok", expired: false },
|
||||
preferences: { deviceName: "StarPilot", cacheMb: 256, initialVolume: 60, output: "bluetooth", outputAddress: "AA:BB:CC:DD:EE:FF", outputName: "Car" },
|
||||
devices: [{ address: "AA:BB:CC:DD:EE:FF", name: "Car", connected: true }], service: null,
|
||||
import: { state: "idle" }, maxUploadBytes: 64 * 1024 * 1024, archiveName: "soloist_release_arm64.tar.gz", cacheChoices: [100, 256],
|
||||
links: { download: DOWNLOAD_PAGE, key: "https://developer.spotify.com/dashboard/soloist", help: "https://developer.spotify.com/" },
|
||||
}
|
||||
|
||||
assert.equal(archivePreflight({ name: "soloist_release_arm64.tar.gz" }), "")
|
||||
assert.equal(archivePreflight({ name: "soloist_release_arm64.tgz" }), "")
|
||||
assert.match(archivePreflight({ name: "soloist_release_x86_64.tar.gz" }), /different computer/)
|
||||
assert.match(archivePreflight({ name: "soloist_release_arm64.tar" }), /unpacked the download/)
|
||||
assert.match(archivePreflight({ name: "soloist" }), /unpacked player/)
|
||||
assert.match(archivePreflight({ name: "photo.jpg" }), /Choose the soloist_release_arm64/)
|
||||
|
||||
assert.match(importProblem({ state: "failed", code: "NOT_OFFICIAL" }), /doesn’t match/)
|
||||
assert.match(importProblem({ state: "failed", code: "OFFLINE" }), /internet/)
|
||||
assert.equal(importProblem({ state: "done" }), "")
|
||||
assert.deepEqual(installChecks({ state: "running", stage: "verifying" }).map((c) => c.status), ["done", "done", "active", "pending", "pending"])
|
||||
assert.deepEqual(installChecks({ state: "failed", stage: "verifying", code: "NOT_OFFICIAL" }).map((c) => c.status),
|
||||
["done", "done", "failed", "pending", "pending"])
|
||||
assert.ok(installChecks({ state: "done" }).every((c) => c.status === "done"))
|
||||
|
||||
assert.equal(releaseInfo({ installed: false }).level, "none")
|
||||
assert.equal(releaseInfo({ ...setup.release, level: "soon", daysLeft: 1 }, "en-US").label, `Version 1.3.9.32 · expires in 1 day (${new Date(setup.release.expires).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })})`)
|
||||
assert.equal(releaseInfo({ ...setup.release, expired: true }, "en-US").level, "expired")
|
||||
assert.match(releaseInfo(setup.release, "en-US").label, /expires on .*2027/)
|
||||
assert.match(releaseInfo({ ...setup.release, level: "soon", daysLeft: 0 }).label, /expires within a day/)
|
||||
assert.match(releaseInfo({ ...setup.release, expired: true }).label, /expired on/)
|
||||
assert.match(releaseInfo({ installed: true }).label, /expiry date unavailable/)
|
||||
|
||||
assert.equal(serviceLabel({ enabled: false }), "Off")
|
||||
assert.equal(serviceLabel({ enabled: true, service: { state: "waiting_output", message: "Waiting for Car to connect over Bluetooth." } }),
|
||||
"Waiting for Car to connect over Bluetooth.")
|
||||
assert.match(serviceLabel({ enabled: true, service: { state: "waiting", message: "" } }), /pick this device/)
|
||||
assert.equal(serviceLabel({ enabled: true, service: { state: "ready", player: { status: "paused" } } }), "Paused")
|
||||
|
||||
assert.equal(positionNow({ ms: 1000, at: 10_000, speed: 1 }, 5000, 12_000), 3000)
|
||||
assert.equal(positionNow({ ms: 1000, at: 10_000, speed: 0 }, 5000, 99_000), 1000)
|
||||
assert.equal(positionNow({ ms: 4000, at: 10_000, speed: 1 }, 5000, 99_000), 5000)
|
||||
assert.equal(formatTime(65_000), "1:05")
|
||||
|
||||
// The key is only ever sent; status responses never carry it, and the page has no field that shows it.
|
||||
assert.match(SpotifyPage.template, /type="password" v-model="keyValue"/)
|
||||
assert.match(SpotifyPage.template, /rel="noopener noreferrer"/)
|
||||
assert.doesNotMatch(SpotifyPage.template, /setup\.key\b/)
|
||||
|
||||
const published = []
|
||||
const requests = []
|
||||
const feed = new SpotifyFeed({
|
||||
publish: (value) => published.push(value),
|
||||
later: () => 1, cancelTimer: () => { },
|
||||
fetcher: async (path, options) => {
|
||||
requests.push([path, options?.method || "GET", options?.body])
|
||||
return { ok: true, status: 200, json: async () => setup }
|
||||
},
|
||||
})
|
||||
await feed.start()
|
||||
assert.deepEqual(feed.setup, setup)
|
||||
await feed.post("./api/spotify/key", { key: "sk_live_0123456789abcdef" })
|
||||
assert.equal(requests.at(-2)[0], "./api/spotify/key")
|
||||
assert.equal(feed.error, "")
|
||||
await feed.control("volume", { volume: 40 })
|
||||
assert.deepEqual(JSON.parse(requests.at(-1)[2]), { action: "volume", args: { volume: 40 } })
|
||||
feed.stop()
|
||||
console.log("spotify ok")
|
||||
|
||||
// Phone setup does not require a Soloist release or key, but does require a selected phone and output.
|
||||
assert.equal(SpotifyPage.computed.setupDone.call({ phoneSource: true, outputChosen: true,
|
||||
setup: { preferences: { phoneAddress: "11:22:33:44:55:66" } } }), true)
|
||||
assert.equal(SpotifyPage.computed.setupDone.call({ phoneSource: true, outputChosen: true,
|
||||
setup: { preferences: { phoneAddress: "" } } }), false)
|
||||
const sourceRequests = []
|
||||
const musicPage = { feed: { post: (...args) => sourceRequests.push(args) } }
|
||||
SpotifyPage.methods.chooseSource.call(musicPage, { target: { value: "phone" } })
|
||||
SpotifyPage.methods.choosePhone.call(musicPage, { target: { value: "11:22:33:44:55:66" } })
|
||||
assert.deepEqual(sourceRequests, [
|
||||
["./api/spotify/preferences", { source: "phone" }],
|
||||
["./api/spotify/preferences", { phoneAddress: "11:22:33:44:55:66" }],
|
||||
])
|
||||
|
||||
// Installing Soloist needs the LAN or tunnel: the page blocks it when the comma says so, and never sends the upload.
|
||||
assert.equal(SpotifyPage.computed.installBlocked.call({ setup: { ...setup, uploadAllowed: false } }), true)
|
||||
assert.equal(SpotifyPage.computed.installBlocked.call({ setup }), false)
|
||||
assert.match(SpotifyPage.computed.uploadReason.call({ installBlocked: true }), /local network or the Galaxy tunnel/)
|
||||
assert.match(SpotifyPage.template, /Installing Spotify needs your local network or the Galaxy tunnel\. Phone audio works over Bluetooth/)
|
||||
const blockedRequests = []
|
||||
const blocked = new SpotifyFeed({
|
||||
publish: () => { }, later: () => 1, cancelTimer: () => { },
|
||||
fetcher: async (path, options) => {
|
||||
blockedRequests.push([path, options?.method || "GET"])
|
||||
return { ok: true, status: 200, json: async () => ({ ...setup, uploadAllowed: false }) }
|
||||
},
|
||||
uploader: async (path) => { blockedRequests.push([path, "UPLOAD"]); return { ok: true, status: 202, json: async () => ({}) } },
|
||||
})
|
||||
await blocked.start()
|
||||
assert.equal(await blocked.upload({ name: "soloist_release_arm64.tar.gz", size: 1024 }), false)
|
||||
assert.ok(!blockedRequests.some(([, method]) => method === "UPLOAD" || method === "POST"))
|
||||
blocked.stop()
|
||||
|
||||
// Over the phone Bluetooth link the page polls much less often.
|
||||
const delays = []
|
||||
for (const [overBluetooth, expected] of [[false, 4000], [true, 15000]]) {
|
||||
const slow = new SpotifyFeed({
|
||||
publish: () => { }, later: (_fn, ms) => { delays.push(ms); return 1 }, cancelTimer: () => { }, overBluetooth: () => overBluetooth,
|
||||
fetcher: async () => ({ ok: true, status: 200, json: async () => setup }),
|
||||
})
|
||||
delays.length = 0
|
||||
await slow.start()
|
||||
assert.ok(delays.includes(expected), `${overBluetooth}: ${delays}`)
|
||||
slow.stop()
|
||||
}
|
||||
assert.match(SpotifyPage.template, /Pair phone/)
|
||||
@@ -52,6 +52,7 @@
|
||||
--on-error: #ffffff;
|
||||
--warning: #ffb865;
|
||||
--success: #4de8e8;
|
||||
--bluetooth: #60a5fa;
|
||||
|
||||
--background: #070712;
|
||||
--surface: rgba(18, 15, 32, 0.65);
|
||||
@@ -123,6 +124,7 @@
|
||||
--on-error: #ffffff;
|
||||
--warning: #c9821f;
|
||||
--success: #0c9b9b;
|
||||
--bluetooth: #2563eb;
|
||||
|
||||
--background: #f2f4fc;
|
||||
--surface: rgba(255, 255, 255, 0.85);
|
||||
@@ -963,6 +965,7 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl
|
||||
.gx-alert--warn { --notice-accent: var(--warning); }
|
||||
.gx-alert--danger { --notice-accent: var(--error); }
|
||||
.gx-alert--success { --notice-accent: var(--success); }
|
||||
.gx-alert--bluetooth { --notice-accent: var(--bluetooth); }
|
||||
.gx-alert__icon { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; font-size: 1.25rem; line-height: 1; color: var(--notice-accent); }
|
||||
.gx-alert__body { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.gx-alert__body > strong { display: block; font-weight: var(--fw-bold); margin-bottom: var(--sp-1); }
|
||||
@@ -1388,6 +1391,8 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.gx-field-group > .gx-select { width: 100%; }
|
||||
.gx-info-card { padding: var(--sp-4); display: grid; gap: var(--sp-3); }
|
||||
.gx-info-card > h3, .gx-info-card > p { margin: 0; }
|
||||
.gx-install-app { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4); }
|
||||
.gx-install-app > .gx-note { flex-basis: 100%; margin: 0; }
|
||||
.gx-install-benefits { display: grid; gap: var(--sp-3); color: var(--text-muted); }
|
||||
.gx-install-benefits li { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.gx-install-benefits i { color: var(--primary); }
|
||||
@@ -1682,7 +1687,7 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.gx-media { display: block; max-width: 100%; height: auto; }
|
||||
.gx-media--spaced { margin-top: var(--sp-4); }
|
||||
.gx-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.gx-truncate { overflow: hidden; text-overflow: ellipsis; }
|
||||
.gx-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gx-device-picker__row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.gx-device-picker__form { padding: var(--sp-2); display: grid; gap: var(--sp-2); }
|
||||
.gx-tunnel__qr { display: block; width: min(250px, 100%); background: white; padding: var(--sp-2); border-radius: var(--radius-sm); }
|
||||
@@ -1704,6 +1709,18 @@ progress { max-width: 100%; accent-color: var(--primary); }
|
||||
.gx-details dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.gx-driving .gx-row { flex-wrap: wrap; }
|
||||
.gx-driving .gx-row > strong { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.gx-bt-card > h3 { margin: 0 0 var(--sp-2); }
|
||||
.gx-bt-adapter__actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2); }
|
||||
.gx-bt-adapter__actions > .gx-btn { padding-inline: var(--sp-2); white-space: nowrap; }
|
||||
.gx-row__desc.gx-bt-device__state { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
||||
.gx-bt-device__state > .gx-status-dot { border: 1.5px solid currentColor; box-sizing: border-box; }
|
||||
.gx-bt-device__state.is-connected { color: var(--success); }
|
||||
.gx-bt-device__state.is-connected > .gx-status-dot { background: currentColor; }
|
||||
.gx-bt-device__actions { display: grid; grid-template-columns: repeat(2, 7.5rem); gap: var(--sp-2); }
|
||||
.gx-bt-device__actions > .gx-btn { padding-inline: var(--sp-2); }
|
||||
@media (max-width: 520px) {
|
||||
.gx-bt-device__actions { grid-template-columns: repeat(2, minmax(0, 1fr)); flex: 1 1 100%; }
|
||||
}
|
||||
.gx-card-grid > .gx-card, .gx-settings__grid > .gx-card, .gx-favorites__slots > .gx-card, .gx-home__grid > .gx-card, .gx-home__pair > .gx-card { margin-bottom: 0; }
|
||||
@media (max-width: 600px) {
|
||||
.gx-details { grid-template-columns: 1fr; gap: var(--sp-1); }
|
||||
@@ -1735,6 +1752,14 @@ progress { max-width: 100%; accent-color: var(--primary); }
|
||||
.gx-replay__player:fullscreen .gx-replay__video { min-height: 0; flex: 1; }
|
||||
}
|
||||
|
||||
.gx-spotify-now { display: grid; gap: var(--sp-3); }
|
||||
.gx-spotify-now__main { display: flex; gap: var(--sp-3); align-items: center; min-width: 0; }
|
||||
.gx-spotify-now__text { min-width: 0; }
|
||||
.gx-spotify-now__text h3, .gx-spotify-now__text p { margin: 0; }
|
||||
.gx-spotify-now__progress { display: grid; gap: 2px; }
|
||||
.gx-spotify-now__controls { align-items: center; flex-wrap: wrap; }
|
||||
.gx-spotify-now__play { min-width: 64px; font-size: 1.4rem; }
|
||||
.gx-spotify-now__volume { display: flex; flex: 1 1 160px; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
/* Auto Learn results use existing cards and plot colors. */
|
||||
.gx-flm__facts { display: flex; flex-wrap: wrap; gap: 1rem 2rem; }
|
||||
.gx-flm__facts dd { margin: .3rem 0 0; }
|
||||
|
||||
@@ -121,3 +121,7 @@
|
||||
}
|
||||
|
||||
.gx-navigation__token-link { margin-bottom: var(--sp-4); }
|
||||
|
||||
.gx-navigation__gps { scroll-margin-top: 100px; }
|
||||
.gx-navigation__gps li { margin-block: var(--sp-2); line-height: 1.5; }
|
||||
.gx-navigation__gps summary { cursor: pointer; min-height: 44px; display: list-item; align-content: center; }
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
"description": "Show StarPilot on your car\u2019s screen",
|
||||
"availability": "local-only",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "Spotify and Phone Audio",
|
||||
"path": "/spotify",
|
||||
"icon": "bi-spotify",
|
||||
"description": "Play Spotify or forward phone music through your comma to your car",
|
||||
"availability": "local-only",
|
||||
"visibility": "authenticated"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requestJson } from "./startup.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { connectionError } from "./polling.js"
|
||||
import { BluetoothDeviceList } from "./bluetooth-devices.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
|
||||
|
||||
export function uploadTimeout(bytes) {
|
||||
@@ -43,6 +44,28 @@ export function uploadPackage(path, options, progress, makeRequest = () => new X
|
||||
}
|
||||
|
||||
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
|
||||
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
|
||||
const HOTSPOT_LINK = `<a class="gx-link" href="#/galaxy">Galaxy Wi-Fi Hotspot</a>`
|
||||
const APP_SETUP = `
|
||||
<div v-if="app === 'HeadunitPad'" class="gx-note" role="note">
|
||||
<strong>HeadunitPad setup (iPad)</strong>
|
||||
<ol>
|
||||
<li>Install <a class="gx-link" href="https://github.com/andyching168/HeadunitPad" target="_blank" rel="noopener noreferrer">HeadunitPad from GitHub</a> on the iPad.</li>
|
||||
<li>Put the iPad on the same Wi-Fi as the comma, or turn on the ${HOTSPOT_LINK} and join its TheGalaxy network.</li>
|
||||
<li>In HeadunitPad, choose Headunit Server or Auto mode, landscape, at 800x480, 1280x720 or 1920x1080.</li>
|
||||
<li>Tap Connect here, then open HeadunitPad.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div v-else-if="app === 'Open Headunit'" class="gx-note" role="note">
|
||||
<strong>Open Headunit setup (Android tablet)</strong>
|
||||
<ol>
|
||||
<li>Install <a class="gx-link" href="https://github.com/andreknieriem/open-headunit/releases" target="_blank" rel="noopener noreferrer">Open Headunit from GitHub</a> on the tablet.</li>
|
||||
<li>Put the tablet on the same Wi-Fi as the comma, or turn on the ${HOTSPOT_LINK} and join its TheGalaxy network.</li>
|
||||
<li>In Open Headunit Settings, add WiFi to Connection mode. Under Wireless Connection, set Wireless Mode to Headunit Server and Server Mode to Auto.</li>
|
||||
<li>Set Resolution to 480p, 720p or 1080p and Video Codec to H.264 or Auto.</li>
|
||||
<li>Tap Connect here, then tap the WiFi button in Open Headunit.</li>
|
||||
</ol>
|
||||
</div>`
|
||||
const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
|
||||
|
||||
export function validPromptInput(prompt, value) {
|
||||
@@ -292,13 +315,15 @@ export class AndroidAutoFeed {
|
||||
}
|
||||
this.uploadProgress = { loaded: 0, total: file.size }
|
||||
const generation = this.generation + 1
|
||||
const transport = (path, options) => this.uploader(path, options, (progress) => {
|
||||
// Slow tunnel links need longer than three minutes for large packages.
|
||||
const timeout = uploadTimeout(file.size)
|
||||
const transport = (path, options) => this.uploader(path, { ...options, timeout }, (progress) => {
|
||||
if (this.active && generation === this.generation) { this.uploadProgress = progress; this.emit() }
|
||||
})
|
||||
const result = await this.request("./api/android-auto/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" }, body: file
|
||||
}, 180000, transport)
|
||||
}, timeout, transport)
|
||||
this.uploadProgress = null
|
||||
this.emit()
|
||||
if (result === null) return false
|
||||
@@ -417,12 +442,13 @@ export function installChecks(job, locale = undefined) {
|
||||
}
|
||||
|
||||
export const AndroidAutoPage = {
|
||||
components: { GxState, GxIconButton, GxNotice, GxDialog, GalaxySettingRow },
|
||||
components: { GxState, GxIconButton, GxNotice, BluetoothNotice, GxDialog, GalaxySettingRow },
|
||||
props: {
|
||||
mode: { type: String, required: true }, localAccess: { type: Boolean, required: true },
|
||||
unauthorized: { type: Function, required: true }
|
||||
},
|
||||
data: () => ({
|
||||
bluetooth: bluetoothConnection(),
|
||||
setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
|
||||
pairValue: "", packageFile: null, uploadProgress: null, installOpen: false, removeOpen: false, installAttempted: false, installBaseline: null,
|
||||
downloadStarted: false, packageMagicError: "", packageChecking: false, packageSelection: 0,
|
||||
@@ -459,12 +485,20 @@ export const AndroidAutoPage = {
|
||||
supportReady() { return !!(this.setup?.enabled && this.setup.identity.installed) },
|
||||
// Returning drivers see a status card; the setup steps fold away below it.
|
||||
wired() { return this.runtime?.connection === "wired" },
|
||||
connectionName() { return this.wired ? "car" : this.selected?.name || "car" },
|
||||
setupDone() { return !!(this.supportReady && (this.selected || this.wired) && !this.pairing?.active) },
|
||||
app() { return HEAD_UNIT_APPS[this.runtime?.connection] || "" },
|
||||
connectionName() { return this.app === "HeadunitPad" ? "iPad" : this.app ? "tablet" : this.wired ? "car" : this.selected?.name || "car" },
|
||||
setupDone() { return !!(this.supportReady && (this.selected || this.wired || this.app) && !this.pairing?.active) },
|
||||
wiredRow() {
|
||||
const detail = "Project over a USB cable from the car to the comma’s USB-C port instead of Bluetooth and Wi-Fi."
|
||||
return switchRow("Wired USB Connection", this.wired, detail + (this.runtime?.wired_blocked ? ` ${this.runtime.wired_blocked}.` : ""))
|
||||
},
|
||||
appRow() {
|
||||
return { label: "App", value: this.app || "Off", choices: ["Off", ...Object.values(HEAD_UNIT_APPS)], available: true, action: true,
|
||||
reason: "Project to a tablet app on the same Wi-Fi as the comma or the Galaxy Wi-Fi Hotspot instead of the car." }
|
||||
},
|
||||
appDisabled() {
|
||||
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running
|
||||
},
|
||||
wiredDisabled() {
|
||||
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running ||
|
||||
(!!this.runtime?.wired_blocked && !this.wired)
|
||||
@@ -488,6 +522,7 @@ export const AndroidAutoPage = {
|
||||
return this.packageMagicError
|
||||
},
|
||||
uploadReason() {
|
||||
if (this.bluetooth) return "Package uploads are not available over Bluetooth. Connect using Galaxy Wi-Fi or LAN."
|
||||
if (!this.packageFile) return "Choose your Android Auto package."
|
||||
if (this.packageError) return this.packageError
|
||||
if (this.packageChecking) return "Checking downloaded file…"
|
||||
@@ -528,7 +563,7 @@ export const AndroidAutoPage = {
|
||||
return ""
|
||||
},
|
||||
connectReason() {
|
||||
if (!this.selected && this.runtime?.connection !== "wired") return "Pair your car in step 2 first."
|
||||
if (!this.selected && !this.wired && !this.app) return "Pair your car in step 2 first."
|
||||
if (!this.setup?.enabled) return "Turn on Android Auto Support in step 1."
|
||||
if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start."
|
||||
if (this.pairing?.active) return "Finish or cancel pairing first."
|
||||
@@ -545,6 +580,10 @@ export const AndroidAutoPage = {
|
||||
},
|
||||
methods: {
|
||||
switchRow,
|
||||
saveApp(index, value) {
|
||||
const connection = Object.keys(HEAD_UNIT_APPS).find(key => HEAD_UNIT_APPS[key] === value) || "wireless"
|
||||
return this.control("connection", { connection })
|
||||
},
|
||||
async begin() {
|
||||
await this.feed.start()
|
||||
if (this.setup?.enabled && this.setup.serviceReady && !this.pairing?.active) this.loadReceivers()
|
||||
@@ -653,7 +692,7 @@ export const AndroidAutoPage = {
|
||||
<h2>Android Auto</h2>
|
||||
<p>Show the StarPilot driving view on your car’s screen. Set up support, {{ wired ? 'plug in your car' : 'pair your car' }}, then connect.</p>
|
||||
</header>
|
||||
<GxNotice tone="warn" v-if="!wired">Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.</GxNotice>
|
||||
<GxNotice tone="warn" v-if="!wired && !app">Android Auto uses the comma’s Wi-Fi to project to your car and disconnects it from your current Wi-Fi network. Use the Galaxy hotspot for local access during projection. Remote Galaxy access requires Internet on the comma.</GxNotice>
|
||||
<GxNotice tone="danger" v-if="error && setup">{{ error }}</GxNotice>
|
||||
<GxNotice v-if="!setup" :busy="!error" :tone="error ? 'danger' : 'info'">{{ error || "Checking Android Auto setup… Reconnecting automatically…" }}</GxNotice>
|
||||
<section v-if="setup && setupDone" class="gx-card gx-aa-step gx-aa-home" aria-labelledby="gx-aa-home-title">
|
||||
@@ -671,6 +710,8 @@ export const AndroidAutoPage = {
|
||||
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />
|
||||
<GalaxySettingRow :row="wiredRow" :index="0" :busy="busy" :disabled="wiredDisabled"
|
||||
:save-value="(index, value) => control('connection', { connection: value === 'On' ? 'wired' : 'wireless' })" />
|
||||
<GalaxySettingRow :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
|
||||
${APP_SETUP}
|
||||
<div class="gx-actions">
|
||||
<a class="gx-btn gx-btn--tonal" href="#/theme_maker/android_auto"><i class="bi bi-layout-text-window" aria-hidden="true"></i> Customize Layout</a>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSteps" aria-controls="gx-aa-steps" @click="showSteps = !showSteps">
|
||||
@@ -803,10 +844,12 @@ export const AndroidAutoPage = {
|
||||
:save-value="(index, value) => control('auto_connect', { enabled: value === 'On' })" />
|
||||
<GalaxySettingRow :row="wiredRow" :index="0" :busy="busy" :disabled="wiredDisabled"
|
||||
:save-value="(index, value) => control('connection', { connection: value === 'On' ? 'wired' : 'wireless' })" />
|
||||
<GalaxySettingRow :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
|
||||
${APP_SETUP}
|
||||
<p v-if="runtime?.companion_name" class="gx-note">Car: {{ runtime.companion_name }}</p>
|
||||
<details class="gx-aa-wiki"><summary>Stuck connecting? Wi-Fi and screen troubleshooting</summary>
|
||||
<ul>
|
||||
<li v-if="!wired"><strong>How it connects:</strong> after Bluetooth, the car (or adapter) creates its own hidden 5 GHz Wi-Fi network and tells the comma how to join. The picture streams over that Wi-Fi.</li>
|
||||
<li v-if="!wired && !app"><strong>How it connects:</strong> after Bluetooth, the car (or adapter) creates its own hidden 5 GHz Wi-Fi network and tells the comma how to join. The picture streams over that Wi-Fi.</li>
|
||||
<li><strong>First connection:</strong> stay in Park or offroad mode, and keep the car’s screen on.</li>
|
||||
<li><strong>Black or frozen screen:</strong> tap Disconnect, wait a few seconds, then connect again. If it keeps happening, turn the car off and on.</li>
|
||||
<li><strong>Car says the phone isn’t compatible:</strong> make sure wireless Android Auto (sometimes called “smartphone connection”) is turned on in the car’s settings, and that step 1 shows Ready.</li>
|
||||
@@ -865,7 +908,9 @@ export const AndroidAutoPage = {
|
||||
<button type="button" class="gx-icon-btn" aria-label="Close" @click="closeInstall"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="gx-aa-sheet__body">
|
||||
<div v-if="installState === 'done'" class="gx-message gx-aa-sheet__done">
|
||||
<div v-if="bluetooth"><h3 id="gx-aa-install-title">Android Auto package installation</h3>
|
||||
<BluetoothNotice feature="Android Auto package downloads and uploads" /></div>
|
||||
<div v-else-if="installState === 'done'" class="gx-message gx-aa-sheet__done">
|
||||
<div class="gx-aa-done-badge"><i class="bi bi-check-lg" aria-hidden="true"></i></div>
|
||||
<h3 id="gx-aa-install-title">You’re all set</h3>
|
||||
<p class="gx-note">{{ expiry.label }}</p>
|
||||
|
||||
@@ -35,6 +35,7 @@ const LongitudinalCurvesPage = page(() => import("./longitudinal-curves.js").the
|
||||
import { DevicePreferencesPage, devicePage } from "./device-preferences.js"
|
||||
const BluetoothPage = page(() => import("./bluetooth.js").then((module) => module.BluetoothPage))
|
||||
const AndroidAutoPage = page(() => import("./android-auto.js").then((module) => module.AndroidAutoPage))
|
||||
const SpotifyPage = page(() => import("./spotify.js").then((module) => module.SpotifyPage))
|
||||
const VehicleControlsPage = page(() => import("./vehicle-controls.js").then((module) => module.VehicleControlsPage))
|
||||
const OnroadLayoutPage = page(() => import("./onroad-layout.js").then((module) => module.OnroadLayoutPage))
|
||||
const GalaxyPage = page(() => import("./galaxy.js").then((module) => module.GalaxyPage))
|
||||
@@ -85,7 +86,7 @@ const auth = new LocalAuth({ publish: (update) => {
|
||||
|
||||
|
||||
createApp({
|
||||
components: { BottomNav, Home, LocalAccess, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, SettingsRecoveryPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile },
|
||||
components: { BottomNav, Home, LocalAccess, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, SettingsRecoveryPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, SpotifyPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile },
|
||||
data: () => ({ state, authState, authForm, route, NAV, routeDirection: 1, updateRestart, deviceRestart, nextLayoutLeavePresentation: "inline" }),
|
||||
computed: {
|
||||
visibleTools() { return state.tools.filter(tool => tool.visibility !== "authenticated" ||
|
||||
@@ -249,6 +250,7 @@ createApp({
|
||||
<LaboratoryPage v-else-if="route.path === '/model_laboratory'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
<BluetoothPage v-else-if="route.path === '/bluetooth'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
<AndroidAutoPage v-else-if="route.path === '/android-auto'" :mode="state.monitorMode" :local-access="authState.localAccess" :unauthorized="sessionExpired" />
|
||||
<SpotifyPage v-else-if="route.path === '/spotify'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
<VehicleControlsPage v-else-if="route.path === '/vehicle'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
<DevicePreferencesPage v-else-if="route.path === '/device-preferences'" :mode="state.monitorMode" :go="go" />
|
||||
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="deviceSettingsPage" :key="deviceSettingsPage" :mode="state.monitorMode" :unauthorized="sessionExpired" :initial-page="deviceSettingsPage" :title="deviceSettingsPage === 'sounds' ? 'Sounds & Alerts' : 'Display'" :return-to="returnToDevice" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { GxNotice } from './notice.js'
|
||||
|
||||
export const bluetoothConnection = () => (globalThis.StarPilotApp || globalThis.window?.StarPilotApp)?.bluetooth?.() === true
|
||||
|
||||
export const BluetoothNotice = {
|
||||
name: 'BluetoothNotice',
|
||||
components: { GxNotice },
|
||||
props: { feature: { type: String, default: 'This feature' } },
|
||||
computed: { canSwitch() { return typeof (globalThis.StarPilotApp || globalThis.window?.StarPilotApp)?.connectionOptions === 'function' } },
|
||||
methods: { connections() { (globalThis.StarPilotApp || globalThis.window?.StarPilotApp)?.connectionOptions?.() } },
|
||||
template: `<GxNotice class="gx-alert--bluetooth" tone="info" icon="bi-bluetooth" :title="feature">
|
||||
Not available over Bluetooth. Connect using Galaxy Wi-Fi or LAN to use this feature.
|
||||
<slot />
|
||||
<button v-if="canSwitch" type="button" class="gx-btn gx-btn--tonal" @click="connections">Connection options</button>
|
||||
</GxNotice>`,
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { GxNotice } from "./notice.js"
|
||||
import { connectionError } from "./polling.js"
|
||||
import { ControllersPage } from "./controllers.js"
|
||||
import { BluetoothDeviceList } from "./bluetooth-devices.js"
|
||||
import { GxDialog } from "./dialog.js"
|
||||
|
||||
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
|
||||
const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "pairing_response", "cancel_pair"])
|
||||
const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "accept_pair", "pairing_response", "cancel_pair"])
|
||||
const PAIR_STATES = new Set(["pairing", "paired", "failed", "canceled"])
|
||||
const BLUETOOTH_ERRORS = {
|
||||
radio_unavailable: "Bluetooth radio support is unavailable on this device.",
|
||||
@@ -14,6 +15,7 @@ const BLUETOOTH_ERRORS = {
|
||||
radio_preference_unavailable: "The Bluetooth power setting could not be read. Try again after restarting the device.",
|
||||
busy: "Bluetooth is being used by another operation. Finish pairing or stop searching, then try again.",
|
||||
park_required: "Park before changing Bluetooth connections.",
|
||||
projection_active: "Android Auto is using this device. Stop Android Auto, then try again.",
|
||||
session_expired: "The pairing session expired. Refresh and start pairing again.",
|
||||
changed: "The Bluetooth device or pairing request changed. Refresh and try again.",
|
||||
}
|
||||
@@ -29,17 +31,26 @@ export function validPairInput(prompt, value) {
|
||||
function validPairing(value) {
|
||||
if (value === null) return true
|
||||
const prompt = value?.prompt
|
||||
return ADDRESS.test(value?.address) && PAIR_STATES.has(value?.state) &&
|
||||
// A window opened for a phone has no address until the first phone asks to pair.
|
||||
const incoming = value?.incoming === true
|
||||
return (incoming ? value.address === "" || ADDRESS.test(value.address) : ADDRESS.test(value?.address)) &&
|
||||
(!incoming || typeof value.name === "string" && value.name.length <= 80) && PAIR_STATES.has(value?.state) &&
|
||||
(prompt === null || (typeof prompt === "object" && /^[0-9a-f]{32}$/.test(prompt.id) &&
|
||||
PROMPT_KINDS.has(prompt.kind) && typeof prompt.value === "string" && prompt.value.length <= 16 &&
|
||||
typeof prompt.displayOnly === "boolean"))
|
||||
}
|
||||
|
||||
function validClaim(value) {
|
||||
return value === undefined || typeof value?.open === "boolean" && Number.isInteger(value.left) && value.left >= 0 && value.left <= 600 &&
|
||||
typeof value.claimed === "string" && value.claimed.length <= 80 && typeof value.paired === "boolean"
|
||||
}
|
||||
|
||||
export function validBluetoothStatus(value) {
|
||||
return value?.version === 1 && typeof value.available === "boolean" && typeof value.parked === "boolean" &&
|
||||
typeof value.powered === "boolean" && typeof value.discovering === "boolean" &&
|
||||
(value.errorCode === null || ["radio_unavailable", "adapter_unavailable", "service_unavailable", "radio_preference_unavailable"].includes(value.errorCode)) &&
|
||||
validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) =>
|
||||
["adapterAddress", "adapterName"].every((key) => value[key] === undefined || typeof value[key] === "string" && value[key].length <= 80) &&
|
||||
validClaim(value.claim) && validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) =>
|
||||
ADDRESS.test(device?.address) && typeof device.name === "string" && device.name.length <= 80 &&
|
||||
[device.paired, device.connected, device.trusted].every((flag) => typeof flag === "boolean"))
|
||||
}
|
||||
@@ -61,7 +72,8 @@ export class BluetoothFeed {
|
||||
|
||||
emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) }
|
||||
stop(cancelPair = false) {
|
||||
if (cancelPair && this.active && this.status?.pairing?.state === "pairing") {
|
||||
// A phone window has to survive the page being hidden: the phone is opened to tap the comma.
|
||||
if (cancelPair && this.active && this.status?.pairing?.state === "pairing" && !this.status.pairing.incoming) {
|
||||
this.fetcher("./api/bluetooth/action", { method: "POST", credentials: "same-origin", keepalive: true,
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel_pair" }) }).catch(() => {})
|
||||
}
|
||||
@@ -142,33 +154,39 @@ export class BluetoothFeed {
|
||||
}
|
||||
|
||||
async action(operation, fields = {}) {
|
||||
if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return
|
||||
if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return false
|
||||
const payload = { operation, ...fields }
|
||||
const result = await this.run("./api/bluetooth/action", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
|
||||
}, operation === "power" ? 35000 : ["connect", "disconnect"].includes(operation) ? 30000 : 15000)
|
||||
if (result) await this.refresh()
|
||||
return result !== null
|
||||
}
|
||||
}
|
||||
|
||||
export const BluetoothPage = {
|
||||
components: { GxState, GxNotice, ControllersPage },
|
||||
components: { GxState, GxNotice, ControllersPage, GxDialog },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
|
||||
data: () => ({ status: null, busy: false, error: "", pairValue: "" }),
|
||||
data: () => ({ status: null, busy: false, error: "", pairValue: "", now: Date.now(), claimAt: Date.now(), forgetting: null }),
|
||||
mounted() {
|
||||
this.feed = new BluetoothFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
|
||||
this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start()
|
||||
document.addEventListener("visibilitychange", this.visibility)
|
||||
this.clock = setInterval(() => { this.now = Date.now() }, 1000)
|
||||
if (this.mode === "local" && !document.hidden) this.feed.start()
|
||||
},
|
||||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
|
||||
beforeUnmount() { clearInterval(this.clock); document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
|
||||
watch: {
|
||||
mode(value) { if (value === "local" && !document.hidden) this.feed?.start(); else this.feed?.stop(true) },
|
||||
'status.pairing.prompt.id'() { this.pairValue = "" },
|
||||
'status.claim'() { this.claimAt = Date.now(); this.now = this.claimAt },
|
||||
},
|
||||
computed: {
|
||||
saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted || device.connected) || [] },
|
||||
saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted) || [] },
|
||||
// A phone running the StarPilot app is connected to comma's Bluetooth bridge but not bonded: list it apart from real pairings.
|
||||
appLinks() { return this.status?.devices?.filter((device) => device.connected && !device.paired && !device.trusted) || [] },
|
||||
nearby() { return this.status?.devices?.filter((device) => !device.paired && !device.trusted && !device.connected) || [] },
|
||||
claimLeft() { return Math.max(0, (this.status?.claim?.left ?? 0) - Math.floor((this.now - this.claimAt) / 1000)) },
|
||||
canAllowPair() { return validPairInput(this.status?.pairing?.prompt, this.pairValue) },
|
||||
},
|
||||
methods: {
|
||||
@@ -181,6 +199,13 @@ export const BluetoothPage = {
|
||||
const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : ""
|
||||
return this.action("pairing_response", { promptId: prompt.id, accepted, value })
|
||||
},
|
||||
async confirmForget() {
|
||||
if (!["confirm", "failed"].includes(this.forgetting?.state) || this.busy) return
|
||||
this.forgetting.state = "working"
|
||||
const forgotten = await this.action("forget", { address: this.forgetting.address })
|
||||
if (this.forgetting) this.forgetting.state = forgotten ? "done" : "failed"
|
||||
},
|
||||
closeForget() { if (this.forgetting?.state !== "working") this.forgetting = null },
|
||||
},
|
||||
template: `
|
||||
<section class="gx-driving" aria-label="Bluetooth" :inert="busy" :aria-busy="busy || undefined">
|
||||
@@ -197,11 +222,20 @@ export const BluetoothPage = {
|
||||
<GxNotice v-else-if="!status.parked" tone="warn">Park to restart the Bluetooth radio.</GxNotice>
|
||||
<div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">Power</span></div>
|
||||
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="Bluetooth power" :checked="status.powered" :disabled="!status.parked || !status.available" @change="$event.target.checked=status.powered; action('power', {enabled: !status.powered})"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
|
||||
<button class="gx-btn gx-btn--tonal" :disabled="!status.powered || !status.parked || status.discovering || status.pairing?.state === 'pairing'" @click="action('accept_pair')">Pair phone</button>
|
||||
</div></section>
|
||||
<section v-if="status.pairing" class="gx-card" role="status"><div class="gx-section__header"><i class="bi bi-link-45deg" aria-hidden="true"></i><span class="gx-section__title">Pairing</span><span class="gx-chip">{{ status.pairing.address }}</span></div>
|
||||
<div class="gx-panel gx-stack"><p v-if="status.pairing.state === 'pairing'">Waiting for the device and any confirmation below.</p>
|
||||
<div v-if="status.claim?.open || status.claim?.claimed" class="gx-card gx-driving__intro gx-bt-card" role="status"><h3>Pair phone</h3>
|
||||
<p v-if="status.claim.open && !status.claim.claimed">Open the StarPilot app and tap <strong>Pair with comma</strong>. No app? Tap <strong>{{ status.adapterName || 'this comma' }}</strong> in your phone’s Bluetooth settings.</p>
|
||||
<p v-else-if="status.claim.claimed && !status.claim.paired">App paired ({{ status.claim.claimed }}). iPhone: tap <strong>{{ status.adapterName || 'this comma' }}</strong> in Settings → Bluetooth to add music. Android pairs music on its own.</p>
|
||||
<p v-else-if="status.claim.claimed">All set. {{ status.claim.claimed }} is paired for the app and music.</p>
|
||||
<p>App: {{ status.claim.claimed || (status.claim.open ? 'waiting' : 'not paired') }} · Music: {{ status.claim.paired || status.pairing?.state === 'paired' ? 'paired' : (status.claim.open ? 'waiting' : 'not paired') }}</p>
|
||||
<p v-if="status.claim.open && claimLeft > 0"><strong>{{ Math.floor(claimLeft / 60) }}:{{ String(claimLeft % 60).padStart(2, '0') }}</strong> left. Accepts one phone.</p></div>
|
||||
<section v-if="status.pairing" class="gx-card" role="status"><div class="gx-section__header"><i class="bi bi-link-45deg" aria-hidden="true"></i><span class="gx-section__title">Pairing</span><span class="gx-chip">{{ status.pairing.name || status.pairing.address }}</span></div>
|
||||
<div class="gx-panel gx-stack"><p v-if="status.pairing.incoming && status.pairing.state === 'pairing' && !status.pairing.address">Open Bluetooth settings on your phone and tap <strong>{{ status.adapterName || 'this comma' }}</strong>.</p>
|
||||
<p v-else-if="status.pairing.state === 'pairing'">Waiting for the device and any confirmation below.</p>
|
||||
<GxNotice v-else :tone="status.pairing.state === 'paired' ? 'success' : status.pairing.state === 'failed' ? 'danger' : 'info'">{{ status.pairing.state === 'paired' ? 'Device paired.' : status.pairing.state === 'canceled' ? 'Pairing canceled.' : 'Pairing did not complete.' }}</GxNotice>
|
||||
<div v-if="status.pairing.prompt" class="gx-stack"><p>{{ status.pairing.prompt.kind === 'confirmation' ? 'Confirm this code matches the device:' :
|
||||
<div v-if="status.pairing.prompt" class="gx-stack"><p>{{ status.pairing.incoming && status.pairing.prompt.displayOnly && status.pairing.prompt.kind === 'confirmation' ? 'Accepted automatically. Tap Pair on your phone if it shows this code:' :
|
||||
status.pairing.prompt.kind === 'confirmation' ? 'Confirm this code matches the device:' :
|
||||
status.pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by the device.' :
|
||||
status.pairing.prompt.kind === 'passkey' ? 'Enter the device passkey.' :
|
||||
status.pairing.prompt.kind === 'authorization' ? 'Allow this device to pair?' : 'Use this code on the device:' }}</p>
|
||||
@@ -214,7 +248,12 @@ export const BluetoothPage = {
|
||||
<p v-if="!saved.length" class="gx-note gx-inset">No saved devices found.</p>
|
||||
<div v-for="device in saved" :key="device.address" class="gx-row"><div class="gx-row__info"><span class="gx-row__label"><i class="bi bi-bluetooth gx-bluetooth__device-icon" aria-hidden="true"></i>{{ device.name || device.address }}</span><span class="gx-row__desc">{{ device.connected ? 'Connected' : 'Disconnected' }} · {{ device.address }}</span></div>
|
||||
<div class="gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="!status.powered" @click="action(device.connected ? 'disconnect' : 'connect', {address:device.address})">{{ device.connected ? 'Disconnect' : 'Connect' }}</button>
|
||||
<button class="gx-btn gx-btn--tonal" @click="action('forget', {address:device.address})">Forget</button></div></div></section>
|
||||
<button class="gx-btn gx-btn--tonal" @click="forgetting = { address: device.address, name: device.name, state: 'confirm' }">Forget</button></div></div></section>
|
||||
<section v-if="appLinks.length" class="gx-card"><div class="gx-section__header"><i class="bi bi-phone" aria-hidden="true"></i><span class="gx-section__title">App Pairings</span></div>
|
||||
<p class="gx-note gx-inset">Phones connected through the StarPilot app. These are not Bluetooth pairings.</p>
|
||||
<div v-for="device in appLinks" :key="device.address" class="gx-row"><div class="gx-row__info"><span class="gx-row__label">{{ device.name }}</span>
|
||||
<span class="gx-row__desc">Connected through the app</span></div>
|
||||
<div class="gx-actions"><button class="gx-btn gx-btn--tonal" @click="forgetting = { address: device.address, name: device.name, state: 'confirm' }">Forget</button></div></div></section>
|
||||
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-broadcast" aria-hidden="true"></i><span class="gx-section__title">Nearby Devices</span>
|
||||
<button class="gx-btn gx-btn--tonal" :disabled="!status.powered" @click="action(status.discovering ? 'stop_scan' : 'scan')"><i class="bi" :class="status.discovering ? 'bi-x-circle' : 'bi-search'" aria-hidden="true"></i>{{ status.discovering ? 'Stop Search' : 'Search' }}</button></div>
|
||||
<p v-if="!nearby.length" class="gx-note gx-inset">{{ status.discovering ? 'Searching…' : 'No nearby devices found.' }}</p>
|
||||
@@ -223,5 +262,19 @@ export const BluetoothPage = {
|
||||
</template>
|
||||
</template>
|
||||
<ControllersPage :mode="mode" :unauthorized="unauthorized" />
|
||||
<GxDialog v-if="forgetting" labelledby="gx-bt-forget-title" describedby="gx-bt-forget-body" alert @close="closeForget">
|
||||
<template v-if="forgetting.state === 'done'">
|
||||
<h3 id="gx-bt-forget-title">{{ forgetting.name }} forgotten</h3>
|
||||
<p id="gx-bt-forget-body">Also remove the comma from the device's Bluetooth list, or it may keep trying to reconnect or fail to pair again later.</p>
|
||||
<div class="gx-settings__controls gx-actions"><button class="gx-btn" @click="closeForget">Done</button></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3 id="gx-bt-forget-title">Forget {{ forgetting.name }}?</h3>
|
||||
<p id="gx-bt-forget-body">The comma will remove it and stop connecting to it. To use it again, you'll need to pair it again.</p>
|
||||
<GxNotice v-if="forgetting.state === 'failed'" tone="danger">{{ error || 'The device could not be forgotten. Try again.' }}</GxNotice>
|
||||
<div class="gx-settings__controls gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="forgetting.state === 'working'" @click="closeForget">Cancel</button>
|
||||
<button class="gx-btn gx-btn--danger" :disabled="forgetting.state === 'working'" @click="confirmForget">{{ forgetting.state === 'working' ? 'Forgetting…' : forgetting.state === 'failed' ? 'Try again' : 'Forget' }}</button></div>
|
||||
</template>
|
||||
</GxDialog>
|
||||
</section>`,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MenuTile } from "./menu-tile.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { GalaxySelect } from "./galaxy-select.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
|
||||
export class CameraSnapshotFeed {
|
||||
constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args),
|
||||
@@ -24,6 +25,7 @@ export class CameraSnapshotFeed {
|
||||
}
|
||||
async capture(camera) {
|
||||
this.stop()
|
||||
if (bluetoothConnection()) return
|
||||
const request = new AbortController()
|
||||
this.request = request
|
||||
this.publish({ image: "", capturing: true, error: "" })
|
||||
@@ -63,10 +65,10 @@ export class CameraSnapshotFeed {
|
||||
|
||||
export const CamerasPage = {
|
||||
name: "CamerasPage",
|
||||
components: { GxNotice, GalaxySelect, MenuTile },
|
||||
components: { GxNotice, BluetoothNotice, GalaxySelect, MenuTile },
|
||||
props: { mode: { type: String, required: true }, go: { type: Function, required: true },
|
||||
unauthorized: { type: Function, required: true } },
|
||||
data: () => ({ camera: "cabin", image: "", capturing: false, error: "" }),
|
||||
data: () => ({ bluetooth: bluetoothConnection(), camera: "cabin", image: "", capturing: false, error: "" }),
|
||||
created() { this.snapshots = new CameraSnapshotFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) },
|
||||
methods: {
|
||||
chooseCamera(event) { this.camera = event.target.value; this.snapshots.stop() },
|
||||
@@ -87,7 +89,8 @@ export const CamerasPage = {
|
||||
</div>
|
||||
<section class="gx-card gx-panel gx-stack"><div class="gx-section__header"><i class="bi bi-camera" aria-hidden="true"></i><span class="gx-section__title">Camera Snapshot</span></div>
|
||||
<p>Turn off the vehicle, choose a camera, then take a snapshot.</p>
|
||||
<template v-if="mode === 'local'">
|
||||
<BluetoothNotice v-if="bluetooth" feature="Camera snapshots" />
|
||||
<template v-else-if="mode === 'local'">
|
||||
<label>Camera
|
||||
<GalaxySelect class="gx-field gx-field--full" aria-label="Camera" :value="camera" :disabled="capturing" @change="chooseCamera">
|
||||
<option value="cabin">Cabin</option><option value="wide">Wide road</option><option value="narrow">Road</option>
|
||||
@@ -153,6 +156,7 @@ export class LiveCameraPreview {
|
||||
image.src = update.image
|
||||
}
|
||||
start() {
|
||||
if (bluetoothConnection()) return
|
||||
this.stopped = false
|
||||
document.addEventListener("visibilitychange", this.visibility)
|
||||
this.refresh()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { requestJson } from './startup.js'
|
||||
import { GxNotice } from './notice.js'
|
||||
import { BluetoothNotice, bluetoothConnection } from './bluetooth-notice.js'
|
||||
|
||||
export function newerCompanionVersion(available, installed) {
|
||||
const parts = version => typeof version === 'string' && /^\d+(?:\.\d+){1,3}$/.test(version) ? version.split('.').map(Number) : []
|
||||
const next = parts(available), current = parts(installed)
|
||||
if (!next.length || !current.length || ![...next, ...current].every(Number.isSafeInteger)) return false
|
||||
for (let index = 0; index < Math.max(next.length, current.length); index++) {
|
||||
const difference = (next[index] || 0) - (current[index] || 0)
|
||||
if (difference) return difference > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function validCompanion(value) {
|
||||
return value?.protocol === 1 && ['enabled', 'connected', 'active', 'appAvailable', 'updateAvailable'].every(key => typeof value[key] === 'boolean') &&
|
||||
Number.isSafeInteger(value.bytes) && value.bytes >= 0 && typeof value.shippedVersion === 'string' &&
|
||||
['active', 'waiting', 'disabled', 'error'].includes(value.state)
|
||||
}
|
||||
|
||||
export const GalaxyCompanion = {
|
||||
components: { GxNotice, BluetoothNotice },
|
||||
emits: ['status'],
|
||||
props: { mode: String, unauthorized: Function },
|
||||
data: () => ({ status: null, error: '', native: !!window.StarPilotApp, sharing: false, autoStart: false,
|
||||
bluetooth: bluetoothConnection(), nativeBytes: 0, busy: false, generation: 0, alive: false }),
|
||||
computed: { windowAutoStart() { return !!window.StarPilotApp?.setAutoStart } },
|
||||
mounted() { this.start() },
|
||||
beforeUnmount() { this.stop() },
|
||||
watch: { mode() { this.stop(); this.start() } },
|
||||
methods: {
|
||||
connectionOptions() { window.StarPilotApp?.connectionOptions?.() },
|
||||
setAutoStart() { window.StarPilotApp.setAutoStart(this.autoStart) },
|
||||
start() { if (this.mode === 'local') { this.alive = true; this.refresh() } },
|
||||
stop() { this.alive = false; this.generation++; clearTimeout(this.timer); this.controller?.abort(); this.status = null; this.$emit('status', null) },
|
||||
adopt(status) {
|
||||
if (!validCompanion(status)) throw new Error('Invalid companion status')
|
||||
this.status = status
|
||||
this.$emit('status', status)
|
||||
if (this.native) {
|
||||
const state = JSON.parse(window.StarPilotApp.uplinkState())
|
||||
this.sharing = state.enabled === true; this.autoStart = state.autoStart === true
|
||||
this.nativeBytes = Number.isSafeInteger(state.bytes) && state.bytes >= 0 ? state.bytes : status.bytes
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
if (!this.alive || this.busy) return
|
||||
clearTimeout(this.timer)
|
||||
const generation = ++this.generation
|
||||
this.controller = new AbortController()
|
||||
try {
|
||||
const status = await requestJson('./api/companion/status', { request: { signal: this.controller.signal } })
|
||||
if (!this.alive || generation !== this.generation) return
|
||||
this.adopt(status); this.error = ''
|
||||
} catch (e) {
|
||||
if (!this.alive || generation !== this.generation) return
|
||||
this.error = e.message
|
||||
if (e.status === 401) { this.stop(); this.unauthorized() }
|
||||
} finally { if (this.alive && generation === this.generation) this.timer = setTimeout(() => this.refresh(), 5000) }
|
||||
},
|
||||
async toggle() {
|
||||
if (this.bluetooth || !this.alive || this.busy || !this.status || this.mode !== 'local') return
|
||||
clearTimeout(this.timer); this.controller?.abort()
|
||||
const generation = ++this.generation
|
||||
this.busy = true
|
||||
try {
|
||||
const enabled = this.native ? !this.sharing : !this.status.enabled
|
||||
const status = await requestJson('./api/companion/settings', { request: { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) } })
|
||||
if (!this.alive || generation !== this.generation) return
|
||||
this.adopt(status)
|
||||
if (this.native) { window.StarPilotApp.setUplinkEnabled(enabled); this.sharing = enabled }
|
||||
this.error = ''
|
||||
} catch (e) {
|
||||
if (!this.alive || generation !== this.generation) return
|
||||
this.error = e.message
|
||||
if (e.status === 401) { this.stop(); this.unauthorized() }
|
||||
} finally {
|
||||
this.busy = false
|
||||
if (this.alive && generation === this.generation) this.timer = setTimeout(() => this.refresh(), 1000)
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `<section class="gx-card gx-panel gx-stack"><h3>Android companion · Connections</h3>
|
||||
<p v-if="native"><button class="gx-btn gx-btn--tonal" @click="connectionOptions">Bluetooth, Galaxy Wi-Fi or LAN</button></p>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Media transfers and phone internet">Phone internet requires the Galaxy hotspot. Settings and small requests remain available.</BluetoothNotice>
|
||||
<p v-else>Share your phone’s mobile data over Galaxy Wi-Fi. Navigation and online services use your mobile plan. Log uploads, software updates and offline map downloads are blocked.</p>
|
||||
<template v-if="!native"><p>Join TheGalaxy Wi-Fi, download the Android app, then allow your browser to install unknown apps when Android asks.</p>
|
||||
<a v-if="status?.appAvailable && mode === 'local'" class="gx-btn" href="./app.apk">Download Android app</a>
|
||||
<p v-else class="gx-note">Connect locally to a comma with the companion APK installed to download.</p>
|
||||
<img v-if="status?.appAvailable && mode === 'local'" src="./api/companion/qr.svg" class="gx-tunnel__qr" alt="Download app over Galaxy Wi-Fi" />
|
||||
</template>
|
||||
<template v-if="status && mode === 'local'"><p>Status: <strong>{{ status.state }}</strong> {{ status.reason }} · {{ ((native ? nativeBytes : status.bytes) / 1048576).toFixed(1) }} MB today</p>
|
||||
<button v-if="!bluetooth" class="gx-btn gx-btn--tonal" :disabled="busy" @click="toggle">{{ (native ? sharing : status.enabled) ? 'Disable phone internet' : 'Enable phone internet' }}</button>
|
||||
<label v-if="!bluetooth && native && !!windowAutoStart"><input type="checkbox" v-model="autoStart" @change="setAutoStart" /> Resume sharing after phone restart (keeps a notification while waiting for Galaxy Wi-Fi)</label>
|
||||
</template><GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
|
||||
</section>`,
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
import { GxState } from "./state.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { requestJson } from "./startup.js"
|
||||
import { InstallApp } from "./install-app.js"
|
||||
import { InstallApp, isIosDevice } from "./install-app.js"
|
||||
import { GalaxyCompanion, newerCompanionVersion } from "./companion.js"
|
||||
import { GalaxyHotspot } from "./hotspot.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
|
||||
const DISMISSED_UPDATE = 'galaxy.companion.dismissedUpdateVersion'
|
||||
const dismissedUpdate = () => { try { return localStorage.getItem(DISMISSED_UPDATE) || '' } catch { return '' } }
|
||||
|
||||
export const GalaxyPage = {
|
||||
name: "GalaxyPage",
|
||||
components: { GxState, GxNotice, InstallApp, GalaxyHotspot },
|
||||
components: { GxState, GxNotice, BluetoothNotice, InstallApp, GalaxyHotspot, GalaxyCompanion },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
|
||||
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
|
||||
legacyPairingAvailable: false, password: "", busy: false, error: "" }),
|
||||
legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
|
||||
bluetooth: bluetoothConnection(), nativeVersion: window.StarPilotApp?.version?.() || '', dismissedUpdateVersion: dismissedUpdate(),
|
||||
ios: isIosDevice() }),
|
||||
computed: {
|
||||
availableAppVersion() { return this.companionStatus?.appAvailable ? this.companionStatus.shippedVersion : '' },
|
||||
installedAppVersion() { return this.nativeVersion || this.companionStatus?.version || '' },
|
||||
appUpdateAvailable() {
|
||||
return this.mode === 'local' && newerCompanionVersion(this.availableAppVersion, this.installedAppVersion)
|
||||
},
|
||||
nativeInstallerAvailable() { return typeof window.StarPilotApp?.installUpdate === 'function' },
|
||||
showAppUpdate() {
|
||||
return this.appUpdateAvailable && this.availableAppVersion !== this.dismissedUpdateVersion
|
||||
},
|
||||
},
|
||||
mounted() { this.load() },
|
||||
methods: {
|
||||
installAppUpdate() { window.StarPilotApp?.installUpdate?.() },
|
||||
dismissAppUpdate() {
|
||||
this.dismissedUpdateVersion = this.availableAppVersion
|
||||
try { localStorage.setItem(DISMISSED_UPDATE, this.dismissedUpdateVersion) } catch {}
|
||||
},
|
||||
async request(path, body) {
|
||||
try {
|
||||
return await requestJson(path, { request: body === undefined ? {} : {
|
||||
@@ -60,6 +83,16 @@ export const GalaxyPage = {
|
||||
template: `
|
||||
<div class="gx-settings gx-tunnel" :inert="busy" :aria-busy="busy || undefined"><header class="gx-settings__header gx-page-header"><div><h2>Galaxy Access & Install</h2>
|
||||
<p>Pair your comma, then install Galaxy from your remote link.</p></div></header>
|
||||
<BluetoothNotice v-if="showAppUpdate && bluetooth && !ios" :feature="'Android app update ' + availableAppVersion">
|
||||
Installed: {{ installedAppVersion }}.
|
||||
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button>
|
||||
</BluetoothNotice>
|
||||
<GxNotice v-else-if="showAppUpdate && !ios" tone="warn" title="Android app update available">
|
||||
Version {{ availableAppVersion }} is available. Installed: {{ installedAppVersion }}.
|
||||
<div class="gx-actions"><button v-if="nativeInstallerAvailable" type="button" class="gx-btn" @click="installAppUpdate">Update app</button>
|
||||
<a v-else class="gx-btn" href="./app.apk">Update app</a>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button></div>
|
||||
</GxNotice>
|
||||
<GxState v-if="mode !== 'local'">Connect to your comma to set up remote access.</GxState>
|
||||
<GxState v-else-if="loading" loading>Checking pairing status…</GxState>
|
||||
<ol v-else class="gx-aa__steps">
|
||||
@@ -69,7 +102,8 @@ export const GalaxyPage = {
|
||||
<div class="gx-aa-step__body">
|
||||
<template v-if="paired">
|
||||
<GxNotice v-if="!tunnelClientAvailable" tone="warn">The remote tunnel client is unavailable. Your link will work when it is installed.</GxNotice>
|
||||
<img src="./api/galaxy/qr.svg" alt="QR code for your Galaxy link" class="gx-tunnel__qr" />
|
||||
<BluetoothNotice v-if="bluetooth" feature="Galaxy QR code">Your Galaxy link remains available below.</BluetoothNotice>
|
||||
<img v-else src="./api/galaxy/qr.svg" alt="QR code for your Galaxy link" class="gx-tunnel__qr" />
|
||||
<p><a :href="url" target="_blank" rel="noopener" class="gx-btn gx-btn--tonal gx-wrap">Open your Galaxy link</a></p>
|
||||
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--danger" @click="unpair">{{ busy ? 'Unpairing…' : 'Unpair' }}</button></div>
|
||||
</template>
|
||||
@@ -86,12 +120,15 @@ export const GalaxyPage = {
|
||||
<li class="gx-card gx-aa-step">
|
||||
<div class="gx-aa-step__head"><span class="gx-aa-step__num" aria-hidden="true">2</span>
|
||||
<div><h3>Install Galaxy</h3><p>Launch Galaxy from your home screen or desktop.</p></div></div>
|
||||
<div class="gx-aa-step__body"><InstallApp :url="url" />
|
||||
<div class="gx-aa-step__body"><InstallApp :url="url" :update-available="appUpdateAvailable" />
|
||||
<p v-if="availableAppVersion && !ios">Android offline app version: <strong>{{ availableAppVersion }}</strong><span v-if="installedAppVersion"> · {{ nativeVersion ? 'Installed' : 'Connected phone' }}: {{ installedAppVersion }}</span></p>
|
||||
<p v-if="!ios"><strong>Download Android APK</strong> to use Galaxy over Galaxy Wi-Fi or your local network without Internet. Download it while connected locally from this page.</p>
|
||||
<p class="gx-note">Your remote link needs Internet and an active tunnel. For offline local access, bookmark your hotspot or local address.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<GalaxyHotspot :mode="mode" :unauthorized="unauthorized" />
|
||||
<GalaxyCompanion v-if="!ios" :mode="mode" :unauthorized="unauthorized" @status="companionStatus = $event" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const GalaxyHotspot = {
|
||||
if (!this.active || this.busy || !this.snapshot?.editable || !this.passwordValid) return
|
||||
clearTimeout(this.timer); this.controller?.abort()
|
||||
const generation = ++this.generation
|
||||
this.controller = new AbortController(); this.busy = true; this.saveError = ''; this.notice = ''
|
||||
this.controller = new AbortController(); this.busy = true; this.error = ''; this.saveError = ''; this.notice = ''
|
||||
try {
|
||||
const value = await requestJson('./api/galaxy/hotspot', { request: { signal: this.controller.signal,
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -90,7 +90,7 @@ export const GalaxyHotspot = {
|
||||
<div class="gx-stack">
|
||||
<p>Network name: <strong>{{ snapshot.config.ssid }}</strong></p>
|
||||
<p class="gx-note">Fixed to TheGalaxy- plus the last four characters of your comma dongle ID. Once saved, the switch and password survive restarts.</p>
|
||||
<label class="gx-field-group">Password <input class="gx-field" :type="showPassword ? 'text' : 'password'" v-model="draft.password" :disabled="busy || !snapshot.editable" maxlength="63" autocomplete="new-password" placeholder="Blank generates a password" /></label>
|
||||
<label class="gx-field-group">Password <input class="gx-field" :type="showPassword ? 'text' : 'password'" v-model="draft.password" :disabled="busy || !snapshot.editable" maxlength="63" autocomplete="new-password" placeholder="Blank generates a password" @input="saveError = ''" /></label>
|
||||
<div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">Show password</span></div>
|
||||
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="Show password" v-model="showPassword" :disabled="busy || !snapshot.editable"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
|
||||
<p v-if="!passwordValid" class="gx-note">Use a 12–63 character password without spaces, or leave it blank to generate one.</p>
|
||||
@@ -107,6 +107,6 @@ export const GalaxyHotspot = {
|
||||
<li><i class="bi bi-arrow-repeat" aria-hidden="true"></i><span>Network or channel changes may interrupt access. Changing the password or disabling the hotspot disconnects phones.</span></li>
|
||||
<li><i class="bi bi-globe" aria-hidden="true"></i><span>The public galaxy.firestar.link address still needs Internet and its tunnel.</span></li>
|
||||
</ul>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice><GxNotice v-if="saveError" tone="danger">{{ saveError }}</GxNotice><GxNotice v-if="notice" tone="success">{{ notice }}</GxNotice>
|
||||
<GxNotice v-if="error || saveError" tone="danger">{{ saveError || error }}</GxNotice><GxNotice v-if="notice" tone="success">{{ notice }}</GxNotice>
|
||||
</section>`,
|
||||
}
|
||||
|
||||
@@ -4,23 +4,36 @@ export function rememberInstallPrompt(event) {
|
||||
deferredPrompt = event
|
||||
}
|
||||
|
||||
export function isIosDevice(agent = typeof navigator === 'undefined' ? '' : navigator.userAgent) {
|
||||
return /iPhone|iPad|iPod/.test(agent)
|
||||
}
|
||||
|
||||
export function isAndroidDevice(agent = typeof navigator === 'undefined' ? '' : navigator.userAgent) {
|
||||
return /Android/.test(agent)
|
||||
}
|
||||
|
||||
export function installHelp({ secure = window.isSecureContext, agent = navigator.userAgent } = {}) {
|
||||
if (!secure) return 'Bookmark this local address for hotspot or Wi-Fi access without Internet. To install Galaxy as a web app (PWA), open your device-specific https://galaxy.firestar.link link from Galaxy Access & Install; this remote link needs Internet.'
|
||||
if (/iPhone|iPad|iPod/.test(agent)) return 'Open Galaxy in Safari, tap Share, then Add to Home Screen. Turn on Open as Web App if shown.'
|
||||
return 'This browser cannot install Galaxy automatically. Open the browser menu and choose Install app or Add to Home Screen. On a Mac in Safari, choose File → Add to Dock.'
|
||||
}
|
||||
|
||||
export function androidInstallHelp() {
|
||||
return 'Connect locally to your comma, then download the Android app from Galaxy Access & Install. Allow your browser to install unknown apps when Android asks.'
|
||||
}
|
||||
|
||||
export const InstallApp = {
|
||||
name: 'InstallApp',
|
||||
props: { url: { type: String, default: '' } },
|
||||
data: () => ({ prompt: null, installed: false, message: '' }),
|
||||
props: { url: { type: String, default: '' }, updateAvailable: { type: Boolean, default: false } },
|
||||
data: () => ({ prompt: null, installed: false, help: false, message: '',
|
||||
android: isAndroidDevice(), ios: isIosDevice() }),
|
||||
computed: { onPublicGalaxy() { return window.location?.hostname === 'galaxy.firestar.link' } },
|
||||
mounted() {
|
||||
this.prompt = deferredPrompt
|
||||
this.displayMode = window.matchMedia('(display-mode: standalone)')
|
||||
this.updateInstalled = () => { this.installed = this.displayMode.matches || navigator.standalone === true }
|
||||
this.onPrompt = (event) => { event.preventDefault(); deferredPrompt = event; this.prompt = event }
|
||||
this.onInstalled = () => { this.installed = true; this.prompt = deferredPrompt = null }
|
||||
this.updateInstalled = () => { this.installed = !!window.StarPilotApp || this.displayMode.matches || navigator.standalone === true }
|
||||
this.onPrompt = (event) => { event.preventDefault(); deferredPrompt = event; this.prompt = event; this.help = false }
|
||||
this.onInstalled = () => { this.installed = true; this.prompt = deferredPrompt = null; this.help = false }
|
||||
this.updateInstalled()
|
||||
window.addEventListener('beforeinstallprompt', this.onPrompt)
|
||||
window.addEventListener('appinstalled', this.onInstalled)
|
||||
@@ -34,7 +47,7 @@ export const InstallApp = {
|
||||
},
|
||||
methods: {
|
||||
installHelp,
|
||||
async install() {
|
||||
async installWeb() {
|
||||
if (!this.prompt) return
|
||||
const prompt = this.prompt
|
||||
this.prompt = deferredPrompt = null
|
||||
@@ -43,15 +56,26 @@ export const InstallApp = {
|
||||
await prompt.userChoice
|
||||
} catch { this.message = installHelp() }
|
||||
},
|
||||
installAndroid() {
|
||||
if (this.updateAvailable && typeof window.StarPilotApp?.installUpdate === 'function') {
|
||||
window.StarPilotApp.installUpdate()
|
||||
return
|
||||
}
|
||||
if (location.protocol === 'http:') location.assign('./app.apk')
|
||||
else { this.message = androidInstallHelp(); this.help = true }
|
||||
},
|
||||
},
|
||||
template: `<div class="gx-install-app">
|
||||
<p v-if="installed" class="gx-note" role="status">Galaxy is installed.</p>
|
||||
<template v-else-if="onPublicGalaxy">
|
||||
<button v-if="prompt" type="button" class="gx-btn" @click="install"><i class="bi bi-download" aria-hidden="true"></i> Install Galaxy</button>
|
||||
<button v-if="prompt" type="button" class="gx-btn" @click="installWeb"><i class="bi bi-download" aria-hidden="true"></i> Install web app</button>
|
||||
<p v-else class="gx-note">{{ installHelp() }}</p>
|
||||
<p v-if="message" class="gx-note">{{ message }}</p>
|
||||
</template>
|
||||
<a v-else-if="url" class="gx-btn" :href="url" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> Open Galaxy to install</a>
|
||||
<a v-else-if="url" class="gx-btn" :href="url" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> Install web app</a>
|
||||
<p v-else class="gx-note">Pair in step 1 to get your install link.</p>
|
||||
<a v-if="ios" class="gx-btn" href="https://apps.apple.com/ca/app/starpilot-bt/id6821039119" target="_blank" rel="noopener"><i class="bi bi-apple" aria-hidden="true"></i> Download StarPilot BT on the App Store</a>
|
||||
<button v-if="android && !ios && (!installed || updateAvailable)" type="button" class="gx-btn" @click="installAndroid"><i class="bi bi-download" aria-hidden="true"></i><span>{{ updateAvailable ? 'Update offline app (Android)' : 'Download Android APK' }}</span></button>
|
||||
<p v-if="help" class="gx-note gx-install-help">{{ message }}</p>
|
||||
</div>`,
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import { CrashReportsFeed } from "./crash-reports.js"
|
||||
import { storedSize } from "./record-deletion.js"
|
||||
import { AndroidAutoLogsFeed, BUNDLE_URL, fileUrl, outcomeLabel } from "./android-auto-logs.js"
|
||||
import { MenuTile } from "./menu-tile.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
|
||||
export const Logs = {
|
||||
name: "Logs",
|
||||
components: { GxState, GxNotice, SystemMonitor, TroubleshootPage, TmuxPage, MenuTile },
|
||||
components: { GxState, GxNotice, BluetoothNotice, SystemMonitor, TroubleshootPage, TmuxPage, MenuTile },
|
||||
props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
|
||||
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle", deleting: false, storageBytes: 0, reportCount: 0 }, search: "",
|
||||
aaLogs: { sessions: [], others: [], status: "idle", error: "" } }),
|
||||
aaLogs: { sessions: [], others: [], status: "idle", error: "" }, bluetooth: bluetoothConnection() }),
|
||||
computed: {
|
||||
visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) },
|
||||
},
|
||||
@@ -101,7 +102,8 @@ export const Logs = {
|
||||
<section v-else-if="path === '/logs/android-auto' && mode === 'local'" class="gx-settings gx-aa-logs" aria-labelledby="gx-aa-logs-title">
|
||||
<header class="gx-settings__header gx-page-header"><div><h2 id="gx-aa-logs-title">Android Auto Logs</h2>
|
||||
<p>The last 20 connection sessions, newest first. The archive includes every session, a readable report for each, Android Auto settings, the car view's renderer logs, and a snapshot of the device (Wi-Fi, Bluetooth and kernel logs, build, CPU load). Hardware addresses are masked.</p></div></header>
|
||||
<div class="gx-settings__controls gx-actions"><a class="gx-btn" :href="bundleUrl()" download><i class="bi bi-file-earmark-zip" aria-hidden="true"></i><span>Download all logs</span><span class="gx-aa-logs__format">ZIP</span></a><button type="button" class="gx-btn gx-btn--tonal" @click="aaFeed.load()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Refresh</span></button></div>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Log downloads">Log summaries remain available.</BluetoothNotice>
|
||||
<div class="gx-settings__controls gx-actions"><a v-if="!bluetooth" class="gx-btn" :href="bundleUrl()" download><i class="bi bi-file-earmark-zip" aria-hidden="true"></i><span>Download all logs</span><span class="gx-aa-logs__format">ZIP</span></a><button type="button" class="gx-btn gx-btn--tonal" @click="aaFeed.load()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Refresh</span></button></div>
|
||||
|
||||
<GxState v-if="aaLogs.status === 'loading'" loading>Loading Android Auto logs…</GxState>
|
||||
<GxNotice v-else-if="aaLogs.status === 'unavailable'" tone="danger">{{ aaLogs.error || 'Android Auto logs are unavailable.' }}</GxNotice>
|
||||
@@ -109,7 +111,7 @@ export const Logs = {
|
||||
<div v-if="aaLogs.status === 'ready'" class="gx-aa-log-list">
|
||||
<article v-for="log in aaLogs.sessions" :key="log.name" class="gx-card gx-aa-log">
|
||||
<div class="gx-aa-log__body"><strong>{{ outcomeLabel(log.outcome) }}</strong><span>{{ log.car || 'Car not identified' }}{{ log.transport ? ' · ' + log.transport : '' }}{{ log.trigger ? ' · ' + log.trigger : '' }}</span><span>{{ reportDate(log.modifiedAt) }} · {{ reportSize(log.size) }}</span></div>
|
||||
<a class="gx-icon-btn gx-aa-log__download" :href="fileUrl(log.name)" :download="log.name" :aria-label="'Download ' + log.name" :title="'Download ' + log.name"><i class="bi bi-download" aria-hidden="true"></i></a>
|
||||
<a v-if="!bluetooth" class="gx-icon-btn gx-aa-log__download" :href="fileUrl(log.name)" :download="log.name" :aria-label="'Download ' + log.name" :title="'Download ' + log.name"><i class="bi bi-download" aria-hidden="true"></i></a>
|
||||
</article>
|
||||
</div>
|
||||
<template v-if="aaLogs.status === 'ready' && aaLogs.others.length">
|
||||
@@ -117,7 +119,7 @@ export const Logs = {
|
||||
<div class="gx-aa-log-list">
|
||||
<article v-for="log in aaLogs.others" :key="log.name" class="gx-card gx-aa-log">
|
||||
<div class="gx-aa-log__body"><strong>{{ log.name }}</strong><span>{{ reportDate(log.modifiedAt) }} · {{ reportSize(log.size) }}</span></div>
|
||||
<a class="gx-icon-btn gx-aa-log__download" :href="fileUrl(log.name)" :download="log.name" :aria-label="'Download ' + log.name" :title="'Download ' + log.name"><i class="bi bi-download" aria-hidden="true"></i></a>
|
||||
<a v-if="!bluetooth" class="gx-icon-btn gx-aa-log__download" :href="fileUrl(log.name)" :download="log.name" :aria-label="'Download ' + log.name" :title="'Download ' + log.name"><i class="bi bi-download" aria-hidden="true"></i></a>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { parseSharedDestination } from "./share-destination.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { connectionError } from "./polling.js"
|
||||
import { NavigationMap } from "./navigation-map.js"
|
||||
import { MapOperationsPanel } from "./map-operations.js"
|
||||
import { OfflineRoadMapsPanel } from "./offline-road-maps.js"
|
||||
import { GalaxySelect } from "./galaxy-select.js"
|
||||
|
||||
export function searchUuid() {
|
||||
if (typeof crypto.randomUUID === "function") return crypto.randomUUID()
|
||||
@@ -20,6 +23,9 @@ export function remainingLocationLease(lease, requestStarted, now = performance.
|
||||
const STATUS = new Set(["disabled", "needsKey", "noDestination", "waitingForLocation", "routing", "guiding", "arrived", "routeUnavailable", "stale"])
|
||||
const ROUTE_AVOIDANCES = [{ key: "avoidTolls", label: "Avoid tolls" }, { key: "avoidHighways", label: "Avoid highways" },
|
||||
{ key: "avoidFerries", label: "Avoid ferries" }]
|
||||
const GPS_MODES = [{ value: "backup", label: "Phone GPS as backup" }, { value: "comma", label: "Comma and car GPS only" },
|
||||
{ value: "phone", label: "Prefer phone GPS" }]
|
||||
const GPS_NAMES = { comma: "Comma", car: "Car", phone: "Phone" }
|
||||
const coordinate = (value) => !!value && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90 &&
|
||||
Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180
|
||||
const text = (value, limit) => typeof value === "string" && value.trim().length > 0 && value.length <= limit
|
||||
@@ -38,12 +44,14 @@ export function validNavigation(value) {
|
||||
!Number.isInteger(value.selectedRoute) || value.selectedRoute < 0 || value.selectedRoute > 2)) return false
|
||||
return !!value && typeof value.enabled === "boolean" && typeof value.isMetric === "boolean" && typeof value.hasKey === "boolean" && STATUS.has(value.status) &&
|
||||
ROUTE_AVOIDANCES.every(({ key }) => value[key] === undefined || typeof value[key] === "boolean") &&
|
||||
(value.gpsSource === undefined || GPS_MODES.some((mode) => mode.value === value.gpsSource)) &&
|
||||
(value.network === undefined || ["online", "offline", "unknown"].includes(value.network)) &&
|
||||
typeof value.revision === "string" && value.revision.length > 0 && value.revision.length <= 128 &&
|
||||
(value.destination === null || validDestination(value.destination)) && Array.isArray(value.favorites) && value.favorites.length <= 100 &&
|
||||
(value.recents === undefined || Array.isArray(value.recents) && value.recents.length <= 10 && value.recents.every(validDestination)) &&
|
||||
(value.routeKey === undefined || typeof value.routeKey === "string" && /^[0-9a-f]{16}$/.test(value.routeKey)) &&
|
||||
(value.location == null || coordinate(value.location) && Number.isFinite(value.location.validForMs) && value.location.validForMs >= 0 && value.location.validForMs <= 2500) &&
|
||||
(value.location == null || coordinate(value.location) && Number.isFinite(value.location.validForMs) && value.location.validForMs >= 0 && value.location.validForMs <= 2500 &&
|
||||
(value.location.source === undefined || value.location.source in GPS_NAMES)) &&
|
||||
value.favorites.every(validDestination) && Array.isArray(value.route) && value.route.length <= 512 && value.route.every(coordinate) &&
|
||||
(instruction === null || !!instruction && ["text", "maneuverType", "maneuverModifier"].every((key) =>
|
||||
typeof instruction[key] === "string" && instruction[key].length <= 1024) &&
|
||||
@@ -285,14 +293,20 @@ export const FavoriteChoices = {
|
||||
|
||||
export const NavigationPage = {
|
||||
name: "NavigationPage",
|
||||
components: { GxNotice, MapOperationsPanel, NavigationMap, OfflineRoadMapsPanel, FavoriteChoices },
|
||||
components: { GxNotice, BluetoothNotice, MapOperationsPanel, NavigationMap, OfflineRoadMapsPanel, FavoriteChoices, GalaxySelect },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
go: { type: Function, default: null }, initialTab: { type: String, default: "route" } },
|
||||
data() { return { data: null, results: [], busy: false, error: "", stale: false, tab: this.initialTab, query: "", token: "", searched: false,
|
||||
data() { return { bluetooth: bluetoothConnection(), data: null, results: [], busy: false, error: "", stale: false, tab: this.initialTab, gpsGuide: "android", query: "", token: "", searched: false,
|
||||
suggestions: [], suggestOpen: false, savedSuggestionPlaces: {}, favoritePicker: null, searchPending: false } },
|
||||
watch: { initialTab(value) { this.tab = value } },
|
||||
computed: {
|
||||
routeAvoidances() { return ROUTE_AVOIDANCES },
|
||||
gpsModes() { return GPS_MODES },
|
||||
gpsLabel() {
|
||||
if (this.mode !== "local" || !this.ready || this.stale) return "GPS Source"
|
||||
const source = this.data?.location?.lastKnown ? null : GPS_NAMES[this.data?.location?.source]
|
||||
return source ? `GPS: ${source}` : "GPS: No fix"
|
||||
},
|
||||
controlsAvailable() { return this.mode === "local" && !!this.data && !this.stale },
|
||||
available() { return this.controlsAvailable && !this.busy && !this.searchPending },
|
||||
ready() { return !!this.data?.enabled && !!this.data?.hasKey },
|
||||
@@ -341,10 +355,31 @@ export const NavigationPage = {
|
||||
if (this.mode !== "local") return
|
||||
this.visibilityHandler = () => document.visibilityState === "hidden" ? this.client.stop() : this.client.start()
|
||||
document.addEventListener("visibilitychange", this.visibilityHandler)
|
||||
if (document.visibilityState !== "hidden") this.client.start()
|
||||
if (document.visibilityState !== "hidden") {
|
||||
this.client.start().then(async () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const share = params.get('share')
|
||||
if (!share || !this.client.data || this.client.stale || !this.client.active) return
|
||||
params.delete('share')
|
||||
history.replaceState(history.state, '', `${location.pathname}${params.size ? '?' + params : ''}${location.hash}`)
|
||||
const parsed = parseSharedDestination(share)
|
||||
if (parsed.destination) await this.client.action('select', { destination: parsed.destination })
|
||||
else {
|
||||
// Text shares can be ambiguous ("Starbucks"); show the results and let the driver pick.
|
||||
this.query = parsed.query
|
||||
await this.client.search(parsed.query)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibilityHandler); clearTimeout(this.suggestTimer); this.client.stop(); this.token = "" },
|
||||
methods: {
|
||||
async openGpsSetup() {
|
||||
this.selectTab('setup')
|
||||
await this.$nextTick()
|
||||
this.$refs.gpsSetup?.focus()
|
||||
this.$refs.gpsSetup?.scrollIntoView({ block: 'start' })
|
||||
},
|
||||
selectTab(tab) {
|
||||
this.tab = tab
|
||||
this.go?.(tab === 'route' ? '/navigation' : `/navigation/${tab}`)
|
||||
@@ -436,6 +471,7 @@ export const NavigationPage = {
|
||||
<div class="gx-tabs gx-actions" role="group" aria-label="Navigation tools">
|
||||
<button v-for="item in [{id:'route',label:'Destination'},{id:'maps',label:'Offline Maps'},{id:'setup',label:'Setup'}]" :key="item.id"
|
||||
type="button" class="gx-btn" :class="tab === item.id ? '' : 'gx-btn--tonal'" :aria-pressed="tab === item.id" @click="selectTab(item.id)">{{ item.label }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="openGpsSetup">{{ gpsLabel }}</button>
|
||||
</div>
|
||||
<div v-if="tab === 'maps'" class="gx-navigation__offline">
|
||||
<OfflineRoadMapsPanel :mode="mode" :unauthorized="unauthorized" :has-key="!!data?.hasKey" :metric="data?.isMetric !== false" />
|
||||
@@ -451,6 +487,39 @@ export const NavigationPage = {
|
||||
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="Navigation" :checked="data.enabled" :disabled="!controlsAvailable"
|
||||
@change="$event.target.checked=data.enabled; client.action('configure', {patch: {enabled: !data.enabled}})"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
|
||||
</section>
|
||||
<section ref="gpsSetup" tabindex="-1" class="gx-card gx-navigation__section gx-navigation__gps" aria-labelledby="navigation-gps-heading">
|
||||
<h3 id="navigation-gps-heading">GPS Source</h3>
|
||||
<p>Choose where your comma gets its position for navigation and map speed limits. Phone GPS comes from the StarPilot app over Bluetooth or local Wi-Fi.</p>
|
||||
<div class="gx-row gx-row--stack gx-row--borderless">
|
||||
<span class="gx-row__label" id="navigation-gps-mode">Use</span>
|
||||
<GalaxySelect class="gx-field gx-field--full" aria-labeledby="navigation-gps-mode" :value="data?.gpsSource || 'backup'" :disabled="!available || !data"
|
||||
@change="client.action('configure', {patch: {gpsSource: $event.target.value}})">
|
||||
<option v-for="option in gpsModes" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</GalaxySelect>
|
||||
</div>
|
||||
<h4>Set up GPS sharing</h4>
|
||||
<div class="gx-actions" role="group" aria-label="GPS setup guides">
|
||||
<button v-for="source in [{id:'android',label:'Android App'},{id:'iphone',label:'iPhone'}]" :key="source.id"
|
||||
type="button" class="gx-btn" :class="gpsGuide === source.id ? '' : 'gx-btn--tonal'" :aria-pressed="gpsGuide === source.id"
|
||||
@click="gpsGuide = source.id">{{ source.label }}</button>
|
||||
</div>
|
||||
<div v-if="gpsGuide === 'android'">
|
||||
<ol><li>Open the StarPilot companion app and connect to your comma over Bluetooth or Galaxy Wi-Fi.</li>
|
||||
<li>Open Connections → Phone GPS → <strong>Share phone GPS with comma</strong>.</li>
|
||||
<li>Allow precise location. Keep the app’s GPS-sharing notification active while driving.</li></ol>
|
||||
<p>To stop, open Connections and select <strong>Stop sharing phone GPS</strong>.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ol><li>Open the StarPilot iPhone companion app and connect to your comma over Bluetooth or Wi-Fi.</li>
|
||||
<li>On the app’s main Settings page, turn on <strong>Share Phone Location</strong>.</li>
|
||||
<li>Set location access to <strong>Always</strong> and enable Precise Location in iPhone Settings. The app continues sharing in the background while the switch is on.</li></ol>
|
||||
<p>Turn off <strong>Share Phone Location</strong> on the app’s main Settings page to stop sharing.</p>
|
||||
</div>
|
||||
<details><summary>About phone GPS and switching phones</summary>
|
||||
<p>The GPS source you choose is used for navigation and for map-based speed limits and curves. The phone must reach the comma over Bluetooth or local Wi-Fi; the remote tunnel doesn't carry GPS.</p>
|
||||
<p>To change phones, stop GPS sharing in the old app and enable it in the other app. Both apps need a version with phone GPS support, and your comma must support receiving it.</p>
|
||||
</details>
|
||||
</section>
|
||||
<section class="gx-card gx-navigation__section">
|
||||
<div class="gx-section__header"><i class="bi bi-signpost-split" aria-hidden="true"></i><span class="gx-section__title">Route preferences</span></div>
|
||||
<p>Apply to new routes and automatic reroutes. Changing a preference recalculates your current route.</p>
|
||||
@@ -583,7 +652,8 @@ export const NavigationPage = {
|
||||
<section class="gx-card gx-navigation__preview" aria-label="Route preview">
|
||||
<div class="gx-navigation__preview-head"><div class="gx-navigation__step"><div><h3>Your route</h3><p role="status">{{ stale ? 'Reconnecting · last received route' : statusLabel }}</p></div></div>
|
||||
<span v-if="summary" class="gx-navigation__trip">{{ duration(summary.duration) }} · {{ distance(summary.distance) }}</span></div>
|
||||
<NavigationMap v-if="ready" :data="data" :stale="stale" />
|
||||
<BluetoothNotice v-if="bluetooth" feature="Map preview">Destination controls remain available.</BluetoothNotice>
|
||||
<NavigationMap v-else-if="ready" :data="data" :stale="stale" />
|
||||
<div v-else class="gx-navigation__empty"><i class="bi bi-signpost-split" aria-hidden="true"></i><p>{{ mode !== 'local' ? 'Connect to your comma to use navigation.' : 'Set up navigation to show the map.' }}</p><button v-if="mode === 'local' && data" type="button" class="gx-btn gx-btn--tonal" @click="selectTab('setup')">Open navigation setup</button></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { markRaw } from "../vendor/vue/vue.esm-browser.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { PollTimer, connectionError } from "./polling.js"
|
||||
import { RasterMap } from "./navigation-map.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
|
||||
|
||||
// Road-line maps for the Android Auto overlay: areas saved on the comma, save-as-you-drive, and the downloader's progress.
|
||||
@@ -118,10 +119,10 @@ export class OfflineRoadsClient {
|
||||
|
||||
export const OfflineRoadMapsPanel = {
|
||||
name: "OfflineRoadMapsPanel",
|
||||
components: { GxNotice, GalaxySettingRow },
|
||||
components: { GxNotice, BluetoothNotice, GalaxySettingRow },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
hasKey: { type: Boolean, default: false }, metric: { type: Boolean, default: true } },
|
||||
data: () => ({ data: null, busy: false, error: "", point: null, name: "", radius: null, confirmDelete: null, notice: "" }),
|
||||
data: () => ({ bluetooth: bluetoothConnection(), data: null, busy: false, error: "", point: null, name: "", radius: null, confirmDelete: null, notice: "" }),
|
||||
created() { this.client = new OfflineRoadsClient({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
|
||||
mounted() {
|
||||
if (this.mode !== "local") return
|
||||
@@ -177,7 +178,7 @@ export const OfflineRoadMapsPanel = {
|
||||
methods: {
|
||||
formatBytes, areaLabel, radiusLabel, switchRow,
|
||||
ensureMap() {
|
||||
if (this.map || !this.hasKey || !this.$refs.canvas) return
|
||||
if (this.bluetooth || this.map || !this.hasKey || !this.$refs.canvas) return
|
||||
this.map = markRaw(new RasterMap(this.$refs.canvas, () => {}))
|
||||
this.map.onTap = (point) => this.pick({ ...point, name: "" })
|
||||
this.map.update({}, false)
|
||||
@@ -245,7 +246,8 @@ export const OfflineRoadMapsPanel = {
|
||||
<GalaxySettingRow :row="switchRow('Save roads as you drive', data?.settings.saveDriven, 'Downloads road lines around and ahead of your car while the map overlay is on and internet is available. Saved lines keep the overlay visible offline; older driven areas are removed when its 600 MB cache fills.')"
|
||||
:index="0" :busy="busy" :disabled="!data" :save-value="(index, value) => setSaveDriven(value === 'On')" />
|
||||
<div class="gx-offline-roads__picker">
|
||||
<div v-if="hasKey" class="gx-offline-roads__map"><canvas ref="canvas" tabindex="0" aria-label="Map. Tap or press Enter to center a new offline area; drag or use arrow keys to pan."></canvas>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Offline area map preview">Saved map settings remain available.</BluetoothNotice>
|
||||
<div v-else-if="hasKey" class="gx-offline-roads__map"><canvas ref="canvas" tabindex="0" aria-label="Map. Tap or press Enter to center a new offline area; drag or use arrow keys to pan."></canvas>
|
||||
<div class="gx-navigation-map__controls"><button class="gx-btn" type="button" @click="zoom(1)" aria-label="Zoom in">+</button>
|
||||
<button class="gx-btn" type="button" @click="zoom(-1)" aria-label="Zoom out">−</button></div>
|
||||
<span class="gx-offline-roads__hint">{{ point ? 'Adjust the radius, name it, then save.' : 'Tap the map to center a new area.' }}</span></div>
|
||||
|
||||
@@ -145,9 +145,10 @@ export function validDocument(document, metadata) {
|
||||
const widget = profile.widgets[id], limits = placementLimits(profile, id, layout)
|
||||
const resizable = widget.resizable, box = widget.box, opacity = widget.opacity
|
||||
return keysEqual(position, ["x", "y", "enabled", ...(resizable ? ["size"] : []), ...(box ? ["width", "height"] : []),
|
||||
...(opacity ? ["opacity"] : []), ...(widget.iconSize ? ["display"] : []), ...(widget.backgroundToggle ? ["background"] : [])]) &&
|
||||
...(opacity ? ["opacity"] : []), ...(widget.iconSize ? ["display"] : []), ...(widget.backgroundToggle ? ["background"] : []), ...(widget.collapseToggle ? ["collapse"] : [])]) &&
|
||||
(!widget.iconSize || ["words", "icons"].includes(position.display)) &&
|
||||
(!widget.backgroundToggle || typeof position.background === "boolean") &&
|
||||
(!widget.collapseToggle || typeof position.collapse === "boolean") &&
|
||||
(!opacity || (Number.isInteger(position.opacity) && position.opacity >= opacity.min && position.opacity <= opacity.max)) &&
|
||||
(!resizable || (Number.isInteger(position.size) && position.size >= resizable.min && position.size <= resizable.max)) &&
|
||||
(!box || (Number.isInteger(position.width) && position.width >= box.minWidth && position.width <= box.maxWidth &&
|
||||
@@ -197,6 +198,7 @@ export function validSnapshot(data) {
|
||||
zone.x + zone.width > bounds.x + bounds.width || zone.y + zone.height > bounds.y + bounds.height) return false
|
||||
}
|
||||
for (const widget of Object.values(profile.widgets)) {
|
||||
if (widget.available != null && (!projection || widget.kind !== "jetlink_status" || typeof widget.available !== "boolean")) return false
|
||||
const area = widget.bounds || bounds
|
||||
if (widget.visualInsetTop != null && (!Number.isFinite(widget.visualInsetTop) || widget.visualInsetTop < 0 || widget.visualInsetTop > area.height)) return false
|
||||
if (!object(widget.colors) || !Object.entries(widget.colors).every(([key, value]) =>
|
||||
@@ -212,10 +214,11 @@ export function validSnapshot(data) {
|
||||
![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
|
||||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
|
||||
widget.opacity.default > widget.opacity.max)) return false
|
||||
if (widget.opacity && (!(["nav_map", "jetlink_status"].includes(widget.kind) || projection && ["nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits"].includes(widget.kind)) ||
|
||||
if (widget.opacity && (!(["nav_map", "jetlink_status"].includes(widget.kind) || projection && ["nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits", "now_playing"].includes(widget.kind)) ||
|
||||
!object(widget.opacity) || ![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
|
||||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
|
||||
widget.opacity.default > widget.opacity.max)) return false
|
||||
if (widget.collapseToggle != null && (!projection || widget.kind !== "now_playing" || widget.collapseToggle !== true)) return false
|
||||
if (widget.backgroundToggle != null && (!projection || !["nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) ||
|
||||
widget.backgroundToggle !== true || !widget.opacity)) return false
|
||||
if (widget.resizable && (!["steering_wheel", "driver_monitor"].includes(widget.kind) || !Number.isInteger(widget.resizable.min) ||
|
||||
|
||||
@@ -226,6 +226,8 @@ export const ONROAD_LAYOUT_TEMPLATE = `
|
||||
</template>
|
||||
<GalaxySettingRow v-if="selectedWidget.backgroundToggle" :row="switchRow('Show background', selectedPosition.background === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setWidgetBackground(value)" />
|
||||
<GalaxySettingRow v-if="selectedWidget.collapseToggle" :row="switchRow('Collapse when paused', selectedPosition.collapse === true)" :index="0"
|
||||
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setWidgetCollapse(value)" />
|
||||
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
|
||||
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
|
||||
<div v-if="selectedWidget.kind === 'clock'" class="gx-row"><div class="gx-row__info"><span class="gx-row__label">Use 24-hour time</span></div>
|
||||
|
||||
@@ -191,6 +191,24 @@ export const LayoutWidgetPreview = {
|
||||
:font-size="profile === 'large' ? 48 : 21" font-weight="600"
|
||||
style="filter: drop-shadow(2px 2px 1px #000000aa)">{{ clock24Hour ? '18:00' : '12:00 PM' }}</text>
|
||||
</template>
|
||||
<g v-else-if="widget.kind === 'now_playing'">
|
||||
<rect x="0" y="0" :width="widget.width" :height="widget.height" :rx="widget.height / 2" fill="#0f0d17" :fill-opacity="(widget.opacity ?? 85) / 100 * .92" />
|
||||
<circle :cx="widget.height / 2" :cy="widget.height / 2" r="30" fill="#c7aef7" />
|
||||
<rect :x="widget.height / 2 - 9" :y="widget.height / 2 - 12" width="6" height="24" rx="3" fill="#0f0d17" />
|
||||
<rect :x="widget.height / 2 + 3" :y="widget.height / 2 - 12" width="6" height="24" rx="3" fill="#0f0d17" />
|
||||
<text :x="widget.height + 4" y="40" font-size="30" font-weight="600" fill="#f5f2fc">Midnight City</text>
|
||||
<text :x="widget.height + 4" y="68" font-size="22" fill="#b8b2c8">M83</text>
|
||||
<rect :x="widget.height + 4" :y="widget.height - 16" :width="widget.width - widget.height * 1.5 - 4" height="4" rx="2" fill="#ffffff" fill-opacity=".2" />
|
||||
<rect :x="widget.height + 4" :y="widget.height - 16" :width="(widget.width - widget.height * 1.5 - 4) * .38" height="4" rx="2" fill="#c7aef7" />
|
||||
</g>
|
||||
<g v-else-if="widget.kind === 'jetlink_status'">
|
||||
<rect x="0" y="0" :width="widget.width" :height="widget.height" :rx="widget.height / 2" fill="#0f0d17" :fill-opacity="(widget.opacity ?? 85) / 100 * .92" />
|
||||
<circle :cx="widget.height / 2" :cy="widget.height / 2" r="9" fill="#46c86e" />
|
||||
<text :x="widget.height - 6" y="38" font-size="28" font-weight="600" fill="#f5f2fc">Link up</text>
|
||||
<text :x="widget.height - 6" y="66" font-size="22" fill="#b8b2c8">Jetlink • iPhone</text>
|
||||
<text :x="widget.width - 70" :y="widget.height / 2 + 12" text-anchor="end" font-size="34" font-weight="600" fill="#f5f2fc">24.6</text>
|
||||
<text :x="widget.width - 30" :y="widget.height / 2 + 12" text-anchor="end" font-size="22" fill="#b8b2c8">ms</text>
|
||||
</g>
|
||||
<template v-else-if="widget.kind === 'speed_limit'">
|
||||
<text x="0" y="10" font-size="24" font-weight="500">LIMIT</text>
|
||||
<path v-if="pending(scene)" d="M90 12 V-8 M84 -2 L90 -8 L96 -2" fill="none" :stroke="palette.text || '#ffffff'" stroke-width="2" />
|
||||
@@ -415,10 +433,12 @@ export const OnroadLayoutPage = {
|
||||
projectionHasPipWidget() {
|
||||
return ["pip_left", "pip_right"].some(id => this.state.draft?.layouts?.large?.[id]?.enabled === true)
|
||||
},
|
||||
widgets() { return Object.entries(this.profile?.widgets || {}).map(([id, widget]) => ({ id, ...widget })) },
|
||||
widgets() { return Object.entries(this.profile?.widgets || {})
|
||||
.filter(([id, widget]) => widget.available !== false || this.layout?.[id]?.enabled)
|
||||
.map(([id, widget]) => ({ id, ...widget })) },
|
||||
layerWidgets() {
|
||||
return orderedWidgetIds(this.state.draft, this.state.data.metadata, this.state.profile)
|
||||
.slice().reverse().map(id => this.widgets.find(widget => widget.id === id)).filter(widget => this.layout[widget.id].enabled)
|
||||
.slice().reverse().map(id => this.widgets.find(widget => widget.id === id)).filter(widget => widget && this.layout[widget.id].enabled)
|
||||
},
|
||||
listWidgets() { return this.state.layerDrag ? this.state.layerDrag.rows.map(id => this.widgets.find(widget => widget.id === id)) : this.layerWidgets },
|
||||
selectionBox() {
|
||||
@@ -442,11 +462,13 @@ export const OnroadLayoutPage = {
|
||||
return {
|
||||
...widget, width, height, ...(widget.iconSize ? { display: this.layout[widget.id].display } : {}),
|
||||
...(widget.opacity ? { opacity: this.layout[widget.id].opacity } : {}),
|
||||
...(widget.backgroundToggle ? { background: this.layout[widget.id].background } : {})
|
||||
...(widget.backgroundToggle ? { background: this.layout[widget.id].background } : {}),
|
||||
...(widget.collapseToggle ? { collapse: this.layout[widget.id].collapse } : {})
|
||||
}
|
||||
})
|
||||
},
|
||||
inactiveWidgets() { return this.widgets.filter(({ id }) => !this.layout[id].enabled || (this.state.drag?.fromTray && this.state.drag.id === id)) },
|
||||
inactiveWidgets() { return this.widgets.filter(({ id, available }) => available !== false &&
|
||||
(!this.layout[id].enabled || (this.state.drag?.fromTray && this.state.drag.id === id))) },
|
||||
availableProfiles() { return this.state.data?.supportedProfiles || (this.state.data?.activeProfile ? [this.state.data.activeProfile] : []) },
|
||||
selectedWidget() { return this.profile?.widgets[this.state.selected] },
|
||||
selectedPosition() { return this.layout?.[this.state.selected] },
|
||||
@@ -801,7 +823,7 @@ export const OnroadLayoutPage = {
|
||||
this.state.notice = ""
|
||||
},
|
||||
add(id) {
|
||||
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
|
||||
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id) || this.profile.widgets[id].available === false) return
|
||||
const before = clone(this.state.draft)
|
||||
this.layout[id].enabled = true
|
||||
if (this.sceneHidden(id)) this.state.scene = "general"
|
||||
@@ -957,6 +979,7 @@ export const OnroadLayoutPage = {
|
||||
},
|
||||
startDrag(id, event, fromTray = false) {
|
||||
if (!this.editable || event.button > 0 || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
|
||||
if (fromTray && this.profile.widgets[id].available === false) return
|
||||
if (fromTray && this.sceneHidden(id)) this.state.scene = "general"
|
||||
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
|
||||
if (!point) return
|
||||
@@ -1046,6 +1069,14 @@ export const OnroadLayoutPage = {
|
||||
this.recordChange(before)
|
||||
this.state.notice = ""
|
||||
},
|
||||
setWidgetCollapse(value) {
|
||||
if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.collapseToggle) return
|
||||
this.finishColorEdit()
|
||||
const before = clone(this.state.draft)
|
||||
this.selectedPosition.collapse = value === 'On'
|
||||
this.recordChange(before)
|
||||
this.state.notice = ""
|
||||
},
|
||||
moveDrag(event) {
|
||||
const drag = this.state.drag
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GxState } from "./state.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { reactive } from "../vendor/vue/vue.esm-browser.js"
|
||||
import { LiveCameraPreview } from "./cameras.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { SettingsFeed, isPipCropRow, settingsBusy } from "./settings.js"
|
||||
import { GalaxySettingRow } from "./galaxy-setting-row.js"
|
||||
import { displayPoint, FORMATS, maskDraft, sourcePoint } from "./pip-geometry.js"
|
||||
@@ -14,7 +15,7 @@ const SIDES = [
|
||||
|
||||
export const PipPage = {
|
||||
name: "PipPage",
|
||||
components: { GxDialog, GxState, GxNotice, GalaxySettingRow },
|
||||
components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
go: { type: Function, required: true } },
|
||||
setup(props) {
|
||||
@@ -41,7 +42,7 @@ export const PipPage = {
|
||||
}
|
||||
}
|
||||
} })
|
||||
return { state, feed, FORMATS, SIDES }
|
||||
return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection() }
|
||||
},
|
||||
mounted() {
|
||||
this._dragIndex = -1
|
||||
@@ -173,20 +174,21 @@ export const PipPage = {
|
||||
<section class="gx-settings gx-pip" aria-label="Blind Spot Camera and Preview saved settings" :inert="busy" :aria-busy="busy || undefined">
|
||||
<header class="gx-settings__header gx-page-header"><div><h2>Blind Spot Camera and Preview</h2>
|
||||
<p>Shows a close-up of the cabin window as you drive. Whether it appears on your turn signal or when a car is in your blind spot depends on your settings. Choose which window each side covers and how much to zoom in.</p></div>
|
||||
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" :disabled="state.cameraWarming" @click="liveCamera.refresh()"><i class="bi bi-camera" aria-hidden="true"></i> Take new snapshot</button><button type="button" class="gx-icon-btn" aria-label="Position side cameras" title="Position side cameras" @click="go('/theme_maker')"><i class="bi bi-layout-wtf" aria-hidden="true"></i></button></div></header>
|
||||
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" :disabled="bluetooth || state.cameraWarming" @click="liveCamera.refresh()"><i class="bi bi-camera" aria-hidden="true"></i> Take new snapshot</button><button type="button" class="gx-icon-btn" aria-label="Position side cameras" title="Position side cameras" @click="go('/theme_maker')"><i class="bi bi-layout-wtf" aria-hidden="true"></i></button></div></header>
|
||||
<GxState v-if="mode !== 'local'">Local saved settings are unavailable in preview.</GxState>
|
||||
<template v-else>
|
||||
<GxNotice v-if="state.data && !state.data.parked" tone="warn">Turn the vehicle off to change these settings.</GxNotice>
|
||||
<GxState v-if="state.status === 'loading'" loading>Reading blind-spot camera preferences and crop positions…</GxState>
|
||||
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read blind-spot camera preferences or crop positions. Reconnecting automatically…</GxNotice>
|
||||
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
|
||||
<GxNotice tone="danger" v-if="state.cameraError">{{ state.cameraError }}</GxNotice>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Cabin camera preview">Saved settings remain available.</BluetoothNotice>
|
||||
<GxNotice tone="danger" v-else-if="state.cameraError">{{ state.cameraError }}</GxNotice>
|
||||
<div v-if="state.data" class="gx-settings__body">
|
||||
|
||||
<section class="gx-card gx-vasm__editor" aria-label="Blind Spot Camera crop editor">
|
||||
<div class="gx-section__header"><i class="bi bi-crop" aria-hidden="true"></i><span class="gx-section__title">Camera Crop</span></div>
|
||||
<p class="gx-note">Drag a circle onto the cabin window you want to watch. Both crops share one size.</p>
|
||||
<p class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}</p>
|
||||
<p v-if="!bluetooth" class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}</p>
|
||||
<div class="gx-actions">
|
||||
<button v-for="side in SIDES" :key="side.key" type="button" class="gx-btn gx-btn--tonal" :disabled="!editable" :aria-pressed="state.activeSide === side.key" :aria-label="side.label" @click="choose(side.key)">{{ side.key === "centerRight" ? "Left" : "Right" }}</button>
|
||||
<button type="button" class="gx-icon-btn" :disabled="!editable || !state[state.activeSide]" aria-label="Clear selected camera crop" title="Clear selected crop" @click="clear(state.activeSide)"><i class="bi bi-eraser"></i></button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GxState } from "./state.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { SnapshotFeed } from "./snapshot-feed.js"
|
||||
import { RecordingActions } from "./recording-actions.js"
|
||||
import { GalaxySelect } from "./galaxy-select.js"
|
||||
@@ -16,14 +17,15 @@ export class LocalHistoryFeed extends SnapshotFeed {
|
||||
static subject = "local recordings"
|
||||
static unavailable = "Local recordings are unavailable."
|
||||
static invalid = "Local recording inventory is unavailable."
|
||||
start() { if (bluetoothConnection()) return this.stop(); return super.start() }
|
||||
}
|
||||
|
||||
export const LocalRecordingsPage = {
|
||||
name: "LocalRecordingsPage",
|
||||
components: { GxState, GxNotice, RecordingActions, GalaxySelect, RouteWorkspace },
|
||||
components: { GxState, GxNotice, BluetoothNotice, RecordingActions, GalaxySelect, RouteWorkspace },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
routePath: { type: String, default: "/recordings" }, go: { type: Function, default: () => {} } },
|
||||
data: () => ({ sortOrder: "newest", query: "", preservedOnly: false, status: "idle", data: null, error: "", entryMode: "footage", exports: null }),
|
||||
data: () => ({ bluetooth: bluetoothConnection(), sortOrder: "newest", query: "", preservedOnly: false, status: "idle", data: null, error: "", entryMode: "footage", exports: null }),
|
||||
computed: {
|
||||
selectedRouteId() { const id = this.routePath.startsWith("/recordings/") ? this.routePath.slice(12) : null; return id },
|
||||
selectedSummary() { return this.data?.routes.find(route => route.routeId === this.selectedRouteId) || null },
|
||||
@@ -44,10 +46,10 @@ export const LocalRecordingsPage = {
|
||||
mounted() {
|
||||
this.visibility = () => {
|
||||
if (document.hidden) { this.feed.stop(); this.exports.close() }
|
||||
else if (this.mode === "local") { this.feed.start(); this.exports.open() }
|
||||
else if (this.mode === "local") { this.feed.start(); this.openExports() }
|
||||
}
|
||||
document.addEventListener("visibilitychange", this.visibility)
|
||||
if (this.mode === "local" && !document.hidden) { this.feed.start(); this.exports.open() }
|
||||
if (this.mode === "local" && !document.hidden) { this.feed.start(); this.openExports() }
|
||||
},
|
||||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop(); this.exports.close() },
|
||||
watch: {
|
||||
@@ -60,6 +62,7 @@ export const LocalRecordingsPage = {
|
||||
mode(value) { if (value !== "local") { this.feed.stop(); this.exports.close() } },
|
||||
},
|
||||
methods: {
|
||||
openExports() { if (bluetoothConnection()) this.exports.close(); else this.exports.open() },
|
||||
hasVideo, replayableSegment, formatDuration,
|
||||
openRoute(route, view = "footage") {
|
||||
this.entryMode = view
|
||||
@@ -71,8 +74,9 @@ export const LocalRecordingsPage = {
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view gx-recordings">
|
||||
<RouteWorkspace v-if="selectedRouteId && mode === 'local'" :key="selectedRouteId" :route-id="selectedRouteId" :summary="selectedSummary" :initial-mode="entryMode" :unauthorized="unauthorized" :exports="exports" @back="go('/recordings')" @changed="refresh" />
|
||||
<div v-show="!selectedRouteId" ref="routeList" class="gx-recordings__browse">
|
||||
<BluetoothNotice v-if="bluetooth" feature="Local recordings and replay" />
|
||||
<RouteWorkspace v-else-if="selectedRouteId && mode === 'local'" :key="selectedRouteId" :route-id="selectedRouteId" :summary="selectedSummary" :initial-mode="entryMode" :unauthorized="unauthorized" :exports="exports" @back="go('/recordings')" @changed="refresh" />
|
||||
<div v-else ref="routeList" class="gx-recordings__browse">
|
||||
<header class="gx-recordings__heading"><h2>Routes</h2><span v-if="mode === 'local' && data" class="gx-note">{{ stats.count }} drives · ~{{ formatDuration(stats.minutes) }}</span></header>
|
||||
<GxState v-if="mode !== 'local'">Local recordings are unavailable in the offline preview.</GxState>
|
||||
<template v-else>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requestJson } from "./startup.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||||
import { GxState } from "./state.js"
|
||||
import { GxIconButton } from "./icon-button.js"
|
||||
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
|
||||
@@ -386,10 +387,10 @@ export class ReplayClient {
|
||||
}
|
||||
|
||||
export const ReplayViewer = {
|
||||
components: { GxNotice, GxState, GxIconButton },
|
||||
components: { GxNotice, BluetoothNotice, GxState, GxIconButton },
|
||||
props: { clientId: { type: String, required: true }, routeId: { type: String, required: true }, startSegment: { type: Number, default: null }, unauthorized: { type: Function, required: true } },
|
||||
emits: ["segment", "busy"],
|
||||
data: () => ({ client: null, replay: null, replayBusy: false, replayError: "", replayDetail: null, replayVideoReady: false, replayBuffering: false, seekPosition: null }),
|
||||
data: () => ({ bluetooth: bluetoothConnection(), client: null, replay: null, replayBusy: false, replayError: "", replayDetail: null, replayVideoReady: false, replayBuffering: false, seekPosition: null }),
|
||||
computed: {
|
||||
playing() { return this.client?.ownedSession === this.replay?.sessionId && !!this.replay?.sessionId && !TERMINAL.has(this.replay.state) },
|
||||
controlsBusy() { return this.replayBusy || !this.replay?.offroad || ["preparing", "seeking", "stopping"].includes(this.replay.state) },
|
||||
@@ -397,6 +398,7 @@ export const ReplayViewer = {
|
||||
canStart() { return this.replay?.offroad && TERMINAL.has(this.replay.state) && !this.replayBusy && this.replayDetail?.playable === true },
|
||||
},
|
||||
mounted() {
|
||||
if (this.bluetooth) return
|
||||
this.client = markRaw(new ReplayClient({ clientId: this.clientId, video: () => this.$refs.video, unauthorized: this.unauthorized, publish: update => {
|
||||
Object.assign(this.$data, update)
|
||||
this.$emit("busy", this.controlsBusy)
|
||||
@@ -407,11 +409,11 @@ export const ReplayViewer = {
|
||||
document.addEventListener("visibilitychange", this.visibility)
|
||||
this.client.open(this.routeId, this.startSegment)
|
||||
},
|
||||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client.close() },
|
||||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client?.close() },
|
||||
methods: {
|
||||
replayTime,
|
||||
stopAndWait() { return this.client.stopAndWait() },
|
||||
close() { return this.client.stop() },
|
||||
close() { return this.client?.stop() },
|
||||
seek(value) { this.seekPosition = null; this.client.control("seek", Number(value)) },
|
||||
cycleRate() {
|
||||
const rates = [.5, 1, 2]
|
||||
@@ -421,12 +423,15 @@ export const ReplayViewer = {
|
||||
},
|
||||
watch: {
|
||||
startSegment(value) {
|
||||
if (!this.client) return
|
||||
this.client.startSegment = value
|
||||
const range = this.replay?.segmentRanges?.find(row => row[0] === value)
|
||||
if (this.playing && range && !(range[1] <= this.replay.position && this.replay.position < range[2])) this.seek(range[1])
|
||||
},
|
||||
},
|
||||
template: `<div class="gx-replay">
|
||||
<BluetoothNotice v-if="bluetooth" feature="Route replay and video" />
|
||||
<template v-else>
|
||||
<div ref="player" class="gx-replay__player">
|
||||
<div class="gx-replay__screen">
|
||||
<video ref="video" class="gx-replay__video" playsinline muted aria-label="Recorded route with driving interface" @waiting="client?.videoWaiting()" @error="client?.videoError()"></video>
|
||||
@@ -452,5 +457,6 @@ export const ReplayViewer = {
|
||||
<p v-if="replayDetail?.reason" class="gx-note">{{ replayDetail.reason }}</p>
|
||||
<details v-if="playing && seekGaps.length" class="gx-route-workspace__help gx-replay__gaps"><summary>{{ seekGaps.length }} missing recording {{ seekGaps.length === 1 ? 'range' : 'ranges' }}</summary><p class="gx-note">Missing recordings: <span v-for="(gap, i) in seekGaps" :key="i">{{ i ? ", " : "" }}{{ replayTime(gap[0]-replay.start) }}–{{ replayTime(gap[1]-replay.start) }}</span>. Seeks move to the nearest recorded position.</p></details>
|
||||
<button v-if="canStart && !replayError" class="gx-btn gx-btn--tonal" @click="client.open(routeId, startSegment)">Play replay</button>
|
||||
</template>
|
||||
</div>`,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
<div class="gx-actions"><p>{{ data.events.length }} events · {{ data.storageIncomplete ? 'At least ' : '' }}{{ storedSize(data.storageBytes) }} stored</p><button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(null)">Delete All</button></div>
|
||||
<GxNotice v-if="data.scanIncomplete" tone="warn">This scan was incomplete. More local motion events may exist.</GxNotice>
|
||||
<GxState v-if="!data.events.length">No local motion events found in this scan.</GxState>
|
||||
<BluetoothNotice v-if="bluetooth && data.events.some(event => event.images?.length)" feature="Motion event images" />
|
||||
<section v-for="event in data.events" :key="event.eventId" class="gx-card gx-home__card">
|
||||
<div class="gx-section__header"><i class="bi" :class="event.kind === 'selfie' ? 'bi-camera' : event.kind === 'alarm' ? 'bi-bell' : 'bi-exclamation-triangle'"></i><h2 class="gx-section__title">{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}</h2></div>
|
||||
<p>System time: {{ systemTime(event.systemTimeMs) }}</p>
|
||||
<small>Event ID: {{ event.eventId }}</small>
|
||||
<button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(event.eventId)">Delete</button>
|
||||
<img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" />
|
||||
<template v-if="!bluetooth"><img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" /></template>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="gx-driving gx-spotify" aria-label="Spotify" :aria-busy="busy || undefined">
|
||||
<header class="gx-page-header">
|
||||
<h2>Spotify and Phone Audio</h2>
|
||||
<p>Play Spotify on your comma, or send music from your phone through the comma to your car.</p>
|
||||
</header>
|
||||
<GxNotice tone="danger" v-if="error && setup">{{ error }}</GxNotice>
|
||||
<GxNotice v-if="!setup" :busy="!error" :tone="error ? 'danger' : 'info'">{{ error || "Checking Spotify setup… Reconnecting automatically…" }}</GxNotice>
|
||||
|
||||
<GxNotice v-if="setup?.release.installed && !phoneSource" :tone="release.level === 'expired' ? 'danger' : release.level === 'soon' ? 'warn' : 'info'"
|
||||
:title="release.label">
|
||||
<p>Soloist expires 90 days after Spotify builds it, regardless of when you install it.</p>
|
||||
<template v-if="release.level === 'soon' || release.level === 'expired'">
|
||||
<p>{{ release.level === 'expired' ? 'Install a newer build to resume Spotify playback.' : 'Install a newer build before this one expires to keep Spotify playing.' }}</p>
|
||||
<button type="button" class="gx-btn" :disabled="busy || installBlocked" @click="openInstall">Update Soloist</button>
|
||||
</template>
|
||||
</GxNotice>
|
||||
|
||||
<section v-if="setup" class="gx-card gx-aa-step" aria-labelledby="gx-music-source-title">
|
||||
<h3 id="gx-music-source-title">Music Source</h3>
|
||||
<label class="gx-stack"><span>Play music from</span>
|
||||
<select class="gx-field" :value="setup.preferences.source || 'spotify'" :disabled="busy || !setup.parked" @change="chooseSource">
|
||||
<option value="spotify">Spotify Connect on the comma</option>
|
||||
<option value="phone">Phone over Bluetooth</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="phoneSource">
|
||||
<p class="gx-note">Pair your phone in the companion app, or tap <strong>Pair phone</strong> on the <a class="gx-link" href="#/bluetooth">Bluetooth page</a>. 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.</p>
|
||||
<label class="gx-stack"><span>Phone</span>
|
||||
<select class="gx-field" :value="setup.preferences.phoneAddress" :disabled="busy || !setup.parked" @change="choosePhone">
|
||||
<option value="">Choose your paired phone…</option>
|
||||
<option v-for="choice in outputChoices" :key="choice.value" :value="choice.value">{{ choice.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<GalaxySettingRow :row="switchRow('Phone Audio', setup.enabled, 'Forward music from the selected phone. Playback and volume stay on your phone.')"
|
||||
:index="0" :busy="busy" :save-value="(index, value) => setEnabled(value === 'On')" />
|
||||
<p class="gx-note" role="status">{{ statusText }}</p>
|
||||
<p class="gx-note">Music only. Calls and microphone audio are not forwarded.</p>
|
||||
</template>
|
||||
<template v-if="setupDone && !phoneSource && !showSetup">
|
||||
<label class="gx-stack"><span>Play on</span>
|
||||
<select class="gx-field" :value="setup.preferences.output === 'bluetooth' ? setup.preferences.outputAddress : setup.preferences.output" :disabled="busy || !setup.parked" @change="chooseOutput">
|
||||
<option value="" disabled>Choose…</option>
|
||||
<option value="android_auto">Android Auto (with StarPilot on the car screen)</option>
|
||||
<optgroup label="Bluetooth">
|
||||
<option v-for="choice in outputChoices" :key="choice.value" :value="choice.value">{{ choice.label }}</option>
|
||||
</optgroup>
|
||||
<option value="test">Silent test (no speaker)</option>
|
||||
</select></label>
|
||||
</template>
|
||||
<small v-if="!setup.parked" class="gx-note">Put the car in Park or use offroad mode to change the source.</small>
|
||||
</section>
|
||||
|
||||
<section v-if="setup && setupDone && !phoneSource" class="gx-card gx-aa-step gx-aa-home gx-spotify-now" aria-labelledby="gx-spotify-now-title">
|
||||
<div class="gx-spotify-now__main">
|
||||
<div class="gx-spotify-now__text">
|
||||
<h3 id="gx-spotify-now-title" class="gx-truncate">{{ track?.title || setup.preferences.deviceName }}</h3>
|
||||
<p v-if="track" class="gx-truncate">{{ track.artists }}<template v-if="track.album"> · {{ track.album }}</template></p>
|
||||
<p role="status" class="gx-note gx-truncate">{{ statusText }}<template v-if="setup.enabled"> · {{ outputName }}</template></p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="track && track.durationMs" class="gx-spotify-now__progress">
|
||||
<input type="range" class="gx-slider" min="0" :max="track.durationMs" step="1000" :value="elapsed" :disabled="!can.has('seek')" aria-label="Position"
|
||||
@change="control('seek', { positionMs: Math.round(Number($event.target.value)) })" />
|
||||
<small class="gx-note">{{ formatTime(elapsed) }} / {{ formatTime(track.durationMs) }}</small>
|
||||
</div>
|
||||
<div v-if="setup.enabled && player?.loggedIn" class="gx-actions gx-spotify-now__controls">
|
||||
<button type="button" class="gx-icon-btn" aria-label="Previous" :disabled="!can.has('skip_prev')" @click="control('previous')"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<button type="button" class="gx-btn gx-spotify-now__play" :aria-label="playing ? 'Pause' : 'Play'" :disabled="!can.has(playing ? 'pause' : 'play') && !!track" @click="togglePlay">
|
||||
<i :class="playing ? 'bi bi-pause-fill' : 'bi bi-play-fill'" aria-hidden="true"></i></button>
|
||||
<button type="button" class="gx-icon-btn" aria-label="Next" :disabled="!can.has('skip_next')" @click="control('next')"><i class="bi bi-skip-end-fill"></i></button>
|
||||
<label v-if="player.volume !== null" class="gx-spotify-now__volume"><i class="bi bi-volume-up" aria-hidden="true"></i>
|
||||
<input type="range" class="gx-slider" min="0" max="100" step="1" :value="volumeDraft ?? player.volume" aria-label="Volume"
|
||||
@input="volumeDraft = Number($event.target.value)" @change="commitVolume" /></label>
|
||||
</div>
|
||||
<button v-if="setup.enabled && player?.loggedIn && !player.active" type="button" class="gx-btn gx-btn--tonal" @click="control('activate')">
|
||||
<i class="bi bi-broadcast" aria-hidden="true"></i> Play Here</button>
|
||||
<GalaxySettingRow :row="switchRow('Spotify', setup.enabled, 'Turn off to stop playing and free up the comma. Your setup stays saved.')"
|
||||
:index="0" :busy="busy" :save-value="(index, value) => setEnabled(value === 'On')" />
|
||||
<div class="gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showSetup" aria-controls="gx-spotify-steps" @click="showSetup = !showSetup">
|
||||
<i :class="showSetup ? 'bi bi-chevron-up' : 'bi bi-gear'" aria-hidden="true"></i> {{ showSetup ? 'Hide Setup' : 'Setup and Settings' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ol v-if="setup" v-show="phoneSource || !setupDone || showSetup" id="gx-spotify-steps" class="gx-aa__steps">
|
||||
<li v-if="!phoneSource" class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': installed }">
|
||||
<div class="gx-aa-step__head">
|
||||
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="installed" class="bi bi-check-lg"></i><template v-else>1</template></span>
|
||||
<div><h3>Spotify Soloist</h3><p>Spotify’s player for devices like your comma. You download it from Spotify, then upload it here.</p></div>
|
||||
</div>
|
||||
<div class="gx-aa-step__body">
|
||||
<div class="gx-aa-status">
|
||||
<span class="gx-aa-pill" :class="'gx-aa-pill--' + release.level" role="status">{{ release.label }}</span>
|
||||
<div class="gx-actions gx-aa-status__actions">
|
||||
<button v-if="!setup.release.installed" class="gx-btn" :disabled="installBlocked" @click="openInstall">Install</button>
|
||||
<button v-else-if="release.level !== 'ok'" class="gx-btn" :disabled="installBlocked" @click="openInstall">Update</button>
|
||||
<button v-else class="gx-btn gx-btn--tonal" :disabled="installBlocked" @click="openInstall">Install newer version</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="installBlocked" class="gx-note" role="status">Installing Spotify needs your local network or the Galaxy tunnel. Phone audio works over Bluetooth.</p>
|
||||
<p v-if="setup.import?.state === 'running' && !installOpen" class="gx-note" role="status">Installing…</p>
|
||||
<p class="gx-note">Each Soloist build works for 90 days from its build date, not its install date. Before it runs out, this step shows <strong>Update</strong>; download again the same way.</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="!phoneSource" class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': setup.keyPresent }">
|
||||
<div class="gx-aa-step__head">
|
||||
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="setup.keyPresent" class="bi bi-check-lg"></i><template v-else>2</template></span>
|
||||
<div><h3>Your API Key</h3><p>Spotify gives each person their own key. Creating one needs Spotify Premium.</p></div>
|
||||
</div>
|
||||
<div class="gx-aa-step__body">
|
||||
<template v-if="setup.keyPresent && !keyEditing">
|
||||
<span class="gx-aa-pill gx-aa-pill--ok" role="status">Key saved on this comma</span>
|
||||
<div class="gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="keyEditing = true">Replace Key</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="removeKey">Remove Key</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a class="gx-btn gx-btn--tonal" :href="setup.links.key" target="_blank" rel="noopener noreferrer">
|
||||
<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> Create Your API Key</a>
|
||||
<form class="gx-stack" @submit.prevent="saveKey">
|
||||
<label class="gx-stack"><span>Paste your key</span>
|
||||
<input class="gx-field" type="password" v-model="keyValue" autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
inputmode="text" maxlength="256" aria-describedby="gx-spotify-key-note" /></label>
|
||||
<div class="gx-actions">
|
||||
<button type="submit" class="gx-btn" :disabled="!keyValid || busy">Save Key</button>
|
||||
<button v-if="keyEditing" type="button" class="gx-btn gx-btn--tonal" @click="keyEditing = false; keyValue = ''">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<small id="gx-spotify-key-note" class="gx-note">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.</small>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': outputChosen }">
|
||||
<div class="gx-aa-step__head">
|
||||
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="outputChosen" class="bi bi-check-lg"></i><template v-else>3</template></span>
|
||||
<div><h3>Where Music Plays</h3><p>Through Android Auto, or your car or a speaker over Bluetooth. Alerts keep playing from the comma itself.</p></div>
|
||||
</div>
|
||||
<div class="gx-aa-step__body">
|
||||
<label class="gx-stack"><span>Play on</span>
|
||||
<select class="gx-field" :value="setup.preferences.output === 'bluetooth' ? setup.preferences.outputAddress : setup.preferences.output" :disabled="busy || !setup.parked" @change="chooseOutput">
|
||||
<option value="" disabled>Choose…</option>
|
||||
<option value="android_auto">Android Auto (with StarPilot on the car screen)</option>
|
||||
<optgroup label="Bluetooth">
|
||||
<option v-for="choice in outputChoices" :key="choice.value" :value="choice.value">{{ choice.label }}</option>
|
||||
</optgroup>
|
||||
<option value="test">Silent test (no speaker)</option>
|
||||
</select></label>
|
||||
<small v-if="!setup.parked" class="gx-note">Put the car in Park or use offroad mode to change this.</small>
|
||||
<p v-if="setup.preferences.output === 'android_auto'" class="gx-note">Music plays through the car while <a class="gx-link" href="#/android-auto">Android Auto</a> 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.</p>
|
||||
<p v-else-if="!outputChoices.length" class="gx-note">No paired devices yet. Pair your car or speaker on the <a class="gx-link" href="#/bluetooth">Bluetooth</a> page first.</p>
|
||||
<p v-else-if="setup.preferences.output === 'bluetooth'" class="gx-note">Don’t see it? Pair it on the <a class="gx-link" href="#/bluetooth">Bluetooth</a> page. In your car, choose <strong>Bluetooth Audio</strong> as the source.</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="!phoneSource" class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': setupDone && setup.enabled && player?.loggedIn }">
|
||||
<div class="gx-aa-step__head">
|
||||
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="setupDone && setup.enabled && player?.loggedIn" class="bi bi-check-lg"></i><template v-else>4</template></span>
|
||||
<div><h3>Connect Spotify</h3><p>Turn it on, then pick <strong>{{ setup.preferences.deviceName }}</strong> in the Spotify app’s device list.</p></div>
|
||||
</div>
|
||||
<div class="gx-aa-step__body">
|
||||
<GalaxySettingRow :row="switchRow('Spotify', setup.enabled, 'Runs Spotify on your comma. Turn off to stop playing; your setup stays saved.')"
|
||||
:index="0" :busy="busy" :save-value="(index, value) => setEnabled(value === 'On')" />
|
||||
<p v-if="setup.enabled" class="gx-note" role="status">{{ statusText }}</p>
|
||||
<p class="gx-note">The first time, your phone has to be on the same Wi‑Fi or hotspot as your comma. After that, Spotify remembers it.</p>
|
||||
<form class="gx-stack" @submit.prevent="saveName">
|
||||
<label class="gx-stack"><span>Name in Spotify</span>
|
||||
<input class="gx-field" type="text" v-model="nameValue" maxlength="40" autocomplete="off" /></label>
|
||||
<button type="submit" class="gx-btn gx-btn--tonal" :disabled="busy || !nameValid || nameValue.trim() === setup.preferences.deviceName">Save Name</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<section v-if="setup && !phoneSource && (setupDone ? showSetup : setup.release.installed)" class="gx-card gx-aa-step" aria-labelledby="gx-spotify-manage-title">
|
||||
<h3 id="gx-spotify-manage-title">Storage and Account</h3>
|
||||
<label class="gx-stack"><span>Music cache</span>
|
||||
<select class="gx-field" :value="setup.preferences.cacheMb" :disabled="busy" @change="saveCache">
|
||||
<option v-for="size in setup.cacheChoices" :key="size" :value="size">{{ size >= 1024 ? (size / 1024) + ' GB' : size + ' MB' }}</option>
|
||||
</select></label>
|
||||
<div class="gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !setup.parked" @click="clearCache">Clear Cache</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !setup.parked" @click="resetOpen = true">Use a Different Spotify Account</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" :disabled="busy || setup.import?.state === 'running'" @click="removeOpen = true">Remove Spotify</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="gx-settings gx-aa-wiki-list" aria-label="Spotify help">
|
||||
<h4>Help</h4>
|
||||
<details class="gx-aa-wiki"><summary>Why do I download Soloist myself?</summary>
|
||||
<p>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.</p>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Spotify doesn’t show my comma</summary>
|
||||
<ol>
|
||||
<li>Make sure step 4 says <strong>Ready to pair</strong>.</li>
|
||||
<li>Put your phone on the same Wi‑Fi or hotspot as your comma. Some hotspots and guest networks block devices from seeing each other.</li>
|
||||
<li>In Spotify, tap the device icon and look for <strong>{{ setup?.preferences.deviceName || 'StarPilot' }}</strong>.</li>
|
||||
</ol>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Does it use my data plan?</summary>
|
||||
<p>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.</p>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Will music cover up StarPilot’s alerts?</summary>
|
||||
<p>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.</p>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Can I use it with Android Auto?</summary>
|
||||
<p>Yes. In step 3 choose <strong>Android Auto</strong>, 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.</p>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>More help from Spotify</summary>
|
||||
<p><a class="gx-link" :href="setup?.links.help || '#'" target="_blank" rel="noopener noreferrer">Spotify’s Soloist getting-started guide</a></p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<GxDialog v-if="resetOpen && setup" labelledby="gx-spotify-reset-title" describedby="gx-spotify-reset-body" alert @close="resetOpen = false">
|
||||
<div><h3 id="gx-spotify-reset-title">Use a different Spotify account?</h3>
|
||||
<p id="gx-spotify-reset-body">This signs your comma out of Spotify and clears its cache. Then pick it in the Spotify app on the account you want.</p></div>
|
||||
<div class="gx-settings__controls gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="resetOpen = false">Cancel</button>
|
||||
<button type="button" class="gx-btn" @click="resetSession">Sign Out</button>
|
||||
</div>
|
||||
</GxDialog>
|
||||
|
||||
<GxDialog v-if="removeOpen && setup" labelledby="gx-spotify-remove-title" describedby="gx-spotify-remove-body" alert @close="removeOpen = false">
|
||||
<div><h3 id="gx-spotify-remove-title">Remove Spotify?</h3>
|
||||
<p id="gx-spotify-remove-body">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.</p></div>
|
||||
<div class="gx-settings__controls gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="removeOpen = false">Cancel</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" @click="removeAll">Remove</button>
|
||||
</div>
|
||||
</GxDialog>
|
||||
|
||||
<GxDialog v-if="installOpen && setup" labelledby="gx-spotify-install-title" class="gx-aa-sheet__dialog" @close="closeInstall">
|
||||
<div class="gx-aa-sheet__toolbar">
|
||||
<button type="button" class="gx-icon-btn" aria-label="Close" @click="closeInstall"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="gx-aa-sheet__body">
|
||||
<div v-if="installState === 'done'" class="gx-message gx-aa-sheet__done">
|
||||
<div class="gx-aa-done-badge"><i class="bi bi-check-lg" aria-hidden="true"></i></div>
|
||||
<h3 id="gx-spotify-install-title">Soloist is installed</h3>
|
||||
<p class="gx-note">{{ release.label }}</p>
|
||||
<ul class="gx-aa-checks"><li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li></ul>
|
||||
<button class="gx-btn gx-aa-cta" @click="closeInstall">Done</button>
|
||||
</div>
|
||||
<div v-else class="gx-stack">
|
||||
<div class="gx-stack gx-aa-sheet__head">
|
||||
<h3 id="gx-spotify-install-title">{{ setup.release.installed ? 'Install a Newer Soloist' : 'Install Spotify Soloist' }}</h3>
|
||||
<p class="gx-note">{{ 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.' }}</p>
|
||||
</div>
|
||||
<ol class="gx-aa-sheet__steps">
|
||||
<li class="gx-aa-step-card" :class="{ 'gx-aa-sheet__step--muted': installState !== 'choose' }">
|
||||
<div class="gx-aa-step-card__head">
|
||||
<span class="gx-aa-step__num gx-aa-step-card__num">1</span>
|
||||
<div class="gx-aa-step-card__title"><strong>Download Soloist</strong>
|
||||
<small>From Spotify’s Downloads page, to this {{ onPhone ? 'phone' : 'computer' }}</small></div>
|
||||
</div>
|
||||
<div class="gx-aa-step-card__body">
|
||||
<a class="gx-btn gx-btn--tonal" :href="setup.links.download" target="_blank" rel="noopener noreferrer" @click="markDownloadStarted">
|
||||
<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> Open Spotify Downloads</a>
|
||||
<ol class="gx-note gx-aa-mirror-steps">
|
||||
<li>Download the <strong>ARMv8/AArch64 Linux</strong> build. The file is named <strong>{{ setup.archiveName }}</strong>.</li>
|
||||
<li>Leave it as it is. Don’t unzip or open it.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</li>
|
||||
<li class="gx-aa-step-card" :class="{ 'gx-aa-sheet__step--muted': installState !== 'choose' }">
|
||||
<div class="gx-aa-step-card__head">
|
||||
<span class="gx-aa-step__num gx-aa-step-card__num">2</span>
|
||||
<div class="gx-aa-step-card__title"><strong>Choose the downloaded file</strong>
|
||||
<small>{{ downloadStarted && downloadsHint ? downloadsHint : 'The file you just downloaded' }}</small></div>
|
||||
</div>
|
||||
<div class="gx-aa-step-card__body">
|
||||
<label class="gx-btn gx-btn--tonal gx-aa-file" :class="{ 'gx-aa-file--disabled': installState !== 'choose' }">
|
||||
<input type="file" :accept="onPhone ? undefined : '.gz,.tgz,application/gzip'" aria-label="Soloist .tar.gz download" :disabled="installState !== 'choose'" @change="chooseArchive" />
|
||||
<i class="bi bi-folder2-open"></i> {{ archiveFile ? 'Choose a Different File' : 'Choose File' }}
|
||||
</label>
|
||||
<small v-if="archiveChecking" role="status" class="gx-note">Checking the file…</small>
|
||||
<div v-if="archiveFile && !archiveError && !archiveChecking" class="gx-chip gx-aa-file-chip">
|
||||
<i class="bi bi-file-earmark-zip"></i><span>{{ archiveFile.name }}</span><small>{{ Math.ceil(archiveFile.size / 1048576) }} MB</small>
|
||||
</div>
|
||||
<small v-if="archiveError" class="gx-aa-bad" role="alert">{{ archiveError }}</small>
|
||||
</div>
|
||||
</li>
|
||||
<li class="gx-aa-step-card">
|
||||
<div class="gx-aa-step-card__head">
|
||||
<span class="gx-aa-step__num gx-aa-step-card__num">3</span>
|
||||
<div class="gx-aa-step-card__title"><strong>Install</strong><small>Send it to your comma and check it with Spotify</small></div>
|
||||
</div>
|
||||
<div class="gx-aa-step-card__body">
|
||||
<button v-if="installState === 'choose'" class="gx-btn gx-aa-cta" :disabled="!!uploadReason" @click="upload">Install</button>
|
||||
<small v-if="installState === 'choose' && uploadReason && archiveFile && !archiveError" class="gx-note" role="status">{{ uploadReason }}</small>
|
||||
<div v-if="installState === 'uploading'" role="status" aria-live="polite" class="gx-stack">
|
||||
<div class="gx-update-progress__track" role="progressbar" aria-label="Sending to your comma" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="uploadPercent">
|
||||
<div class="gx-update-progress__fill" :style="{ width: uploadPercent + '%' }"></div>
|
||||
</div>
|
||||
<small>Sending to your comma… {{ uploadPercent }}%</small>
|
||||
</div>
|
||||
<ul v-if="['checking', 'failed'].includes(installState)" class="gx-aa-checks" aria-live="polite">
|
||||
<li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li>
|
||||
</ul>
|
||||
<GxNotice v-if="installState === 'failed'" tone="danger">
|
||||
<p>{{ problem }}</p>
|
||||
<button class="gx-btn gx-btn--tonal" @click="chooseAgain">Choose Another File</button>
|
||||
</GxNotice>
|
||||
<p v-if="error && installState === 'choose' && installAttempted" class="gx-aa-bad" role="alert">{{ error }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="gx-stack gx-aa-sheet__footer">
|
||||
<small class="gx-note gx-aa-trademark">Spotify is a trademark of Spotify AB. StarPilot is not affiliated with or endorsed by Spotify.</small>
|
||||
<small class="gx-note">The uploaded file is deleted after install. Maximum size {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GxDialog>
|
||||
</section>`,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
<section class="gx-settings gx-vasm" aria-label="V-ASM saved settings" :inert="busy" :aria-busy="busy || undefined">
|
||||
<header class="gx-settings__header gx-page-header"><div><h2>V-ASM Spot Monitoring</h2>
|
||||
<p>Highlight the side windows a driver would check in the cabin camera, and tune visual spot-monitoring options.</p></div>
|
||||
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" :disabled="state.cameraWarming" @click="liveCamera.refresh()"><i class="bi bi-camera" aria-hidden="true"></i> Take new snapshot</button></div></header>
|
||||
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" :disabled="bluetooth || state.cameraWarming" @click="liveCamera.refresh()"><i class="bi bi-camera" aria-hidden="true"></i> Take new snapshot</button></div></header>
|
||||
<GxNotice v-if="mode === 'local' && state.data && !state.data.parked" tone="warn">Turn the vehicle off to change these settings.</GxNotice>
|
||||
<GxState v-if="mode !== 'local'">Local saved settings are unavailable in preview.</GxState>
|
||||
<template v-else>
|
||||
<GxState v-if="state.status === 'loading'" loading>Reading camera regions and spot-monitoring preferences…</GxState>
|
||||
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read camera regions or spot-monitoring preferences. Reconnecting automatically…</GxNotice>
|
||||
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
|
||||
<GxNotice tone="danger" v-if="state.cameraError">{{ state.cameraError }}</GxNotice>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Cabin camera preview">Saved settings remain available.</BluetoothNotice>
|
||||
<GxNotice tone="danger" v-else-if="state.cameraError">{{ state.cameraError }}</GxNotice>
|
||||
<div v-if="state.data" class="gx-settings__body">
|
||||
|
||||
<section class="gx-card gx-vasm__editor" aria-label="Camera window region editor">
|
||||
<div class="gx-section__header"><i class="bi bi-bounding-box" aria-hidden="true"></i><span class="gx-section__title">Camera Window Regions</span></div>
|
||||
<p>The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.</p>
|
||||
<p class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}</p>
|
||||
<p v-if="!bluetooth" class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}</p>
|
||||
<div class="gx-actions">
|
||||
<button v-for="side in ['cameraRight', 'cameraLeft']" :key="side" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="state.activeSide === side" :disabled="!drawable" :aria-label="displaySide(side)" @click="state.activeSide=side">{{ side === 'cameraRight' ? 'Left' : 'Right' }}</button>
|
||||
<button type="button" class="gx-icon-btn" :disabled="!drawable || !state[state.activeSide]?.length" aria-label="Undo last region point" title="Undo last point" @click="undo(state.activeSide)"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
|
||||
Reference in New Issue
Block a user