mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 01:23:58 +08:00
Group Galaxy audio settings under Android Auto and standardize banners
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route('**/js/boot.js', route => route.fulfill({ body: '' }))
|
||||
await page.goto(process.env.GALAXY_URL || 'http://127.0.0.1:8765/')
|
||||
await page.evaluate(async () => {
|
||||
const { createApp } = await import('./vendor/vue/vue.esm-browser.js')
|
||||
const { GxNotice } = await import('./js/notice.js')
|
||||
const { BluetoothNotice } = await import('./js/bluetooth-notice.js')
|
||||
window.StarPilotApp = { connectionOptions() {} }
|
||||
document.body.innerHTML = '<main class="gx-content"><div id="fixture" class="gx-settings"></div></main>'
|
||||
createApp({ components: { GxNotice, BluetoothNotice }, data: () => ({ clicks: 0 }),
|
||||
template: `<GxNotice tone="danger">This page could not finish loading.<template #actions><button class="gx-btn" @click="clicks++">Reload page</button></template></GxNotice>
|
||||
<output>{{ clicks }}</output>
|
||||
<GxNotice v-for="tone in ['info', 'success', 'warn', 'danger']" :tone="tone" :title="tone"><p>A message that wraps onto multiple lines on narrow screens, with enough detail to explain what happens next.</p><p>Additional guidance.</p><template #actions><button class="gx-btn gx-btn--tonal">Choose another file with a longer name</button><a class="gx-btn" href="#fixture">Open settings</a></template></GxNotice>
|
||||
<GxNotice busy>Connecting to your comma…</GxNotice>
|
||||
<GxNotice>Unable to load https://example.com/abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz</GxNotice>
|
||||
<BluetoothNotice feature="Replay"/>`,
|
||||
}).mount('#fixture')
|
||||
})
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme)
|
||||
for (const width of [320, 390, 768, 1280, 3440]) {
|
||||
await page.setViewportSize({ width, height: 1200 })
|
||||
const failures = await page.locator('.gx-alert').evaluateAll(banners => banners.flatMap(banner => {
|
||||
const box = banner.getBoundingClientRect()
|
||||
const body = banner.querySelector('.gx-alert__body').getBoundingClientRect()
|
||||
const text = banner.querySelector('.gx-alert__text').getBoundingClientRect()
|
||||
const icon = banner.querySelector('.gx-alert__icon').getBoundingClientRect()
|
||||
const actions = banner.querySelector('.gx-alert__actions')
|
||||
const issues = []
|
||||
if (Math.abs((icon.top + icon.bottom) / 2 - (body.top + body.bottom) / 2) > 1) issues.push('icon vertical centering')
|
||||
if (banner.scrollWidth > banner.clientWidth || body.right > box.right) issues.push('overflow')
|
||||
if (actions) {
|
||||
const actionBox = actions.getBoundingClientRect()
|
||||
if (actionBox.top < text.bottom + 7) issues.push('actions need a separate row and gap')
|
||||
if (Math.abs(actionBox.left - text.left) > 1) issues.push('action alignment')
|
||||
if (banner.querySelector('.gx-alert__text button')) issues.push('button inside message')
|
||||
}
|
||||
return issues
|
||||
}))
|
||||
assert.deepEqual(failures, [], `${theme}/${width}`)
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
}
|
||||
}
|
||||
await page.getByRole('button', { name: 'Reload page', exact: true }).click()
|
||||
assert.equal(await page.locator('output').textContent(), '1')
|
||||
assert.equal(await page.locator('.gx-alert--danger').first().getAttribute('role'), 'alert')
|
||||
assert.equal(await page.locator('.gx-alert--info').first().getAttribute('role'), 'status')
|
||||
assert.deepEqual(errors, [])
|
||||
await page.screenshot({ path: '/tmp/galaxy-notice-banners.png', fullPage: true })
|
||||
await browser.close()
|
||||
console.log('Notice banners: all tones, actions, loading, Bluetooth, wrapping, themes and responsive layouts passed.')
|
||||
})().catch(error => { console.error(error); process.exit(1) })
|
||||
@@ -464,3 +464,12 @@ try {
|
||||
else delete globalThis.navigator
|
||||
}
|
||||
console.log('Android Auto page: manage view, pairing gates and phone detection passed')
|
||||
|
||||
// Audio setup belongs to Android Auto; old bookmarks still open the same page.
|
||||
const catalog = JSON.parse(readFileSync(new URL("../web/data/catalog.json", import.meta.url), "utf8"))
|
||||
assert.ok(!catalog.tools.some(tool => tool.path === "/spotify" || tool.path === "/android-auto/audio"))
|
||||
const audioTool = catalog.tools.find(tool => tool.path === "/android-auto").children
|
||||
.find(tool => tool.path === "/android-auto/audio")
|
||||
assert.equal(audioTool.name, "Spotify and Phone Audio")
|
||||
assert.match(AndroidAutoPage.template, /href="#\/android-auto\/audio"/)
|
||||
assert.ok(app.includes(`SpotifyPage v-else-if="['/android-auto/audio', '/spotify'].includes(route.path)"`))
|
||||
|
||||
@@ -19,7 +19,7 @@ class PreviewContractTest(unittest.TestCase):
|
||||
self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "partial-preview"], ["/logs"])
|
||||
self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "local-only"],
|
||||
["/bluetooth", "/cameras", "/galaxy", "/manage_models", "/model_laboratory", "/navigation", "/system",
|
||||
"/theme_maker", "/tuning/plots", "/vehicle", "/android-auto", "/spotify"])
|
||||
"/theme_maker", "/tuning/plots", "/vehicle", "/android-auto"])
|
||||
self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "always"], [])
|
||||
self.assertTrue(all(tool["availability"] in {"always", "unavailable", "partial-preview", "local-only"} for tool in catalog["tools"]))
|
||||
self.assertTrue(all(tool["name"] and tool["description"] and tool["icon"] for tool in catalog["tools"]))
|
||||
|
||||
@@ -960,18 +960,22 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl
|
||||
.gx-view h2, .gx-monitor h2 { margin-top: 0; }
|
||||
.gx-note { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||
|
||||
/* Shared notice banner (ported from the original Galaxy GxNotice). */
|
||||
.gx-alert { --notice-accent: var(--primary); align-items: flex-start; backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border: 1px solid var(--glass-border); border-left: 4px solid var(--notice-accent); border-radius: var(--radius-md); display: flex; gap: var(--sp-3); width: 100%; margin: 0 0 var(--sp-4); padding: var(--sp-4); background: color-mix(in srgb, var(--notice-accent) 12%, transparent); }
|
||||
/* Shared banners: message first, optional actions on their own row. */
|
||||
.gx-alert { --notice-accent: var(--primary); align-items: center; backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border: 1px solid var(--glass-border); border-left: 4px solid var(--notice-accent); border-radius: var(--radius-md); display: flex; gap: var(--sp-3); width: 100%; margin: 0 0 var(--sp-4); padding: var(--sp-4); background: color-mix(in srgb, var(--notice-accent) 12%, transparent); }
|
||||
.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); }
|
||||
.gx-alert__text { color: var(--on-surface-variant); font-size: var(--fs-sm); line-height: 1.5; }
|
||||
.gx-alert__body { flex: 1; min-width: 0; display: grid; gap: var(--sp-2); overflow-wrap: anywhere; }
|
||||
.gx-alert__body > strong { display: block; font-weight: var(--fw-bold); margin: 0; line-height: 1.5; }
|
||||
.gx-alert__text { color: var(--on-surface); font-size: var(--fs-sm); line-height: 1.5; }
|
||||
.gx-alert__text > :first-child { margin-top: 0; }
|
||||
.gx-alert__text > :last-child { margin-bottom: 0; }
|
||||
.gx-alert__text p { margin: 0; }
|
||||
.gx-alert__text .gx-btn { margin-top: var(--sp-2); }
|
||||
.gx-alert__text > p + p { margin-top: var(--sp-2); }
|
||||
.gx-alert__actions { margin: 0; justify-content: flex-start; }
|
||||
.gx-alert__actions .gx-btn { max-width: 100%; white-space: normal; overflow-wrap: anywhere; padding-block: var(--sp-2); box-shadow: none; }
|
||||
.gx-alert .gx-spinner { border-top-color: var(--notice-accent); }
|
||||
.gx-settings > .gx-card, .gx-settings > .gx-alert, .gx-stack > .gx-card, .gx-stack > .gx-alert { margin-bottom: 0; }
|
||||
|
||||
|
||||
@@ -180,17 +180,18 @@
|
||||
"name": "Android Auto",
|
||||
"path": "/android-auto",
|
||||
"icon": "bi-phone",
|
||||
"description": "Show StarPilot on your car\u2019s screen",
|
||||
"description": "Show StarPilot on your car\u2019s screen, customize its layout, save offline maps, and set up Spotify or phone audio",
|
||||
"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"
|
||||
"visibility": "authenticated",
|
||||
"children": [
|
||||
{
|
||||
"name": "Spotify and Phone Audio",
|
||||
"path": "/android-auto/audio",
|
||||
"icon": "bi-spotify",
|
||||
"description": "Play Spotify or forward phone music through your comma to your car",
|
||||
"availability": "local-only"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -865,6 +865,7 @@ export const AndroidAutoPage = {
|
||||
<div><h3>Customize Your Dashboard</h3><p>Once connected, customize your dashboard setup.</p></div>
|
||||
</div>
|
||||
<div class="gx-aa-step__body">
|
||||
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/android-auto/audio"><i class="bi bi-spotify" aria-hidden="true"></i> Spotify and Phone Audio</a>
|
||||
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/theme_maker/android_auto">Customize Layout</a>
|
||||
<a class="gx-btn gx-btn--tonal gx-aa-cta" href="#/navigation/maps"><i class="bi bi-map" aria-hidden="true"></i> Offline Road Maps</a>
|
||||
<p class="gx-note">Save road lines for the map overlay so it keeps drawing roads with no signal.</p>
|
||||
@@ -1003,7 +1004,7 @@ export const AndroidAutoPage = {
|
||||
</ul>
|
||||
<GxNotice v-if="installState === 'failed'" tone="danger">
|
||||
<p>{{ problem }}</p>
|
||||
<button class="gx-btn gx-btn--tonal" @click="chooseAgain">Choose Another File</button>
|
||||
<template #actions><button class="gx-btn gx-btn--tonal" @click="chooseAgain">Choose Another File</button></template>
|
||||
</GxNotice>
|
||||
<GxNotice v-if="error && installState === 'choose' && installAttempted" tone="danger">{{ error }}</GxNotice>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@ createApp({
|
||||
<GxNotice v-if="authState.error" tone="danger">{{ authState.error }}</GxNotice>
|
||||
|
||||
</section>
|
||||
<GxNotice v-else-if="state.pageError" tone="danger">{{ state.pageError }} <button class="gx-btn" @click="reloadPage">Reload page</button></GxNotice>
|
||||
<GxNotice v-else-if="state.pageError" tone="danger">{{ state.pageError }} <template #actions><button class="gx-btn" @click="reloadPage">Reload page</button></template></GxNotice>
|
||||
<Home v-else-if="route.path === '/'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
|
||||
<LocalRecordingsPage v-else-if="isActive('/recordings') && authState.localAccess" :mode="state.monitorMode" :unauthorized="sessionExpired" :route-path="route.path" :go="go" />
|
||||
<div v-else-if="isActive('/recordings')" class="gx-stack">
|
||||
@@ -252,7 +252,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" />
|
||||
<SpotifyPage v-else-if="['/android-auto/audio', '/spotify'].includes(route.path)" :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" />
|
||||
|
||||
@@ -11,6 +11,6 @@ export const BluetoothNotice = {
|
||||
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>
|
||||
<template #actions v-if="canSwitch"><button type="button" class="gx-btn gx-btn--tonal" @click="connections">Connection options</button></template>
|
||||
</GxNotice>`,
|
||||
}
|
||||
|
||||
@@ -90,9 +90,9 @@ export const GalaxyPage = {
|
||||
</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>
|
||||
<template #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>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button></template>
|
||||
</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>
|
||||
|
||||
@@ -19,6 +19,8 @@ export const GxNotice = {
|
||||
template: `<div class="gx-alert" :class="toneClass" :role="tone === 'danger' ? 'alert' : 'status'">
|
||||
<span v-if="busy" class="gx-spinner gx-alert__icon" aria-hidden="true"></span>
|
||||
<i v-else class="bi gx-alert__icon" :class="iconClass" aria-hidden="true"></i>
|
||||
<div class="gx-alert__body"><strong v-if="title">{{ title }}</strong><div class="gx-alert__text"><slot /></div></div>
|
||||
<div class="gx-alert__body"><strong v-if="title">{{ title }}</strong><div class="gx-alert__text"><slot /></div>
|
||||
<div v-if="$slots.actions" class="gx-actions gx-alert__actions"><slot name="actions" /></div>
|
||||
</div>
|
||||
</div>`,
|
||||
}
|
||||
|
||||
@@ -80,12 +80,13 @@ export const LocalRecordingsPage = {
|
||||
<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>
|
||||
<GxNotice v-if="exports?.job?.owned" tone="info"><div class="gx-route-workspace__job">
|
||||
<GxNotice v-if="exports?.job?.owned" tone="info">
|
||||
<span>{{ exports.running ? 'Exporting replay' : exports.job.state === 'ready' ? 'Replay export ready' : 'Replay export ' + exports.job.state }} · {{ exports.job.segment == null ? 'Saved route' : 'Segment ' + exports.job.segment }}<template v-if="exports.running"> · {{ Math.round(exports.job.progress * 100) }}%</template></span>
|
||||
<a v-if="exports.job.state === 'ready'" class="gx-btn gx-btn--tonal" :href="exports.downloadUrl" :download="exports.job.filename">Download replay {{ exports.job.segment == null ? 'route' : 'segment' }} (.mp4)</a>
|
||||
<template #actions><a v-if="exports.job.state === 'ready'" class="gx-btn gx-btn--tonal" :href="exports.downloadUrl" :download="exports.job.filename">Download replay {{ exports.job.segment == null ? 'route' : 'segment' }} (.mp4)</a>
|
||||
<button v-if="exports.running" class="gx-btn gx-btn--tonal" :disabled="exports.busy" @click="exports.cancel()">Cancel export</button>
|
||||
<button class="gx-btn gx-btn--tonal" @click="openRoute({routeId: exports.job.routeId}, 'replay')">Open route</button>
|
||||
</div></GxNotice>
|
||||
</template>
|
||||
</GxNotice>
|
||||
<section class="gx-card gx-recordings__library">
|
||||
<div class="gx-recordings__filters">
|
||||
<input class="gx-field" type="search" aria-label="Search routes" placeholder="Search drives, dates or IDs…" v-model="query" />
|
||||
|
||||
@@ -541,7 +541,7 @@ export const ReplayViewer = {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GxNotice v-if="replayError" tone="danger">{{ replayError }}<button v-if="!playing" class="gx-btn gx-btn--tonal" :disabled="replayBusy" @click="client.open(routeId, startSegment)">Retry replay</button><button v-else class="gx-btn gx-btn--tonal" :disabled="controlsBusy || !replay.codec || replay.mediaReady === false" @click="client.beginMedia(replay)">Retry video</button></GxNotice>
|
||||
<GxNotice v-if="replayError" tone="danger">{{ replayError }}<template #actions><button v-if="!playing" class="gx-btn gx-btn--tonal" :disabled="replayBusy" @click="client.open(routeId, startSegment)">Retry replay</button><button v-else class="gx-btn gx-btn--tonal" :disabled="controlsBusy || !replay.codec || replay.mediaReady === false" @click="client.beginMedia(replay)">Retry video</button></template></GxNotice>
|
||||
<GxNotice v-if="replay && !replay.offroad" tone="warn">Turn off the vehicle before replaying a drive.</GxNotice>
|
||||
<GxNotice v-if="replay?.errorCode" tone="warn">Replay stopped: {{ replay.errorCode.replaceAll('_', ' ') }}</GxNotice>
|
||||
<GxNotice v-if="replay?.sessionId && !playing && !canStart" tone="info">Another replay is active. Close it in its viewer before starting this route.</GxNotice>
|
||||
|
||||
@@ -196,7 +196,7 @@ export const RouteWorkspace = {
|
||||
<RecordingActions v-if="route" :recording="route" collapsed :unauthorized="unauthorized" @changed="managed" />
|
||||
</header>
|
||||
<GxState v-if="status === 'loading'" loading>Reading saved route…</GxState>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}<button v-if="status === 'unavailable'" class="gx-btn gx-btn--tonal" @click="load">Retry route</button></GxNotice>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}<template #actions v-if="status === 'unavailable'"><button class="gx-btn gx-btn--tonal" @click="load">Retry route</button></template></GxNotice>
|
||||
<template v-if="status === 'ready' && route">
|
||||
<GxNotice v-if="!route.inventoryComplete" tone="warn">{{ route.reason || 'Some route files could not be inspected.' }} Route downloads require a complete inventory.</GxNotice>
|
||||
<div class="gx-route-workspace__body">
|
||||
|
||||
@@ -418,6 +418,8 @@ export const SpotifyPage = {
|
||||
<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>
|
||||
</template>
|
||||
<template #actions v-if="release.level === 'soon' || release.level === 'expired'">
|
||||
<button type="button" class="gx-btn" :disabled="busy || installBlocked" @click="openInstall">Update Soloist</button>
|
||||
</template>
|
||||
</GxNotice>
|
||||
@@ -708,7 +710,7 @@ export const SpotifyPage = {
|
||||
</ul>
|
||||
<GxNotice v-if="installState === 'failed'" tone="danger">
|
||||
<p>{{ problem }}</p>
|
||||
<button class="gx-btn gx-btn--tonal" @click="chooseAgain">Choose Another File</button>
|
||||
<template #actions><button class="gx-btn gx-btn--tonal" @click="chooseAgain">Choose Another File</button></template>
|
||||
</GxNotice>
|
||||
<p v-if="error && installState === 'choose' && installAttempted" class="gx-aa-bad" role="alert">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user