mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-15 00:43:46 +08:00
Add offroad alert volume previews to Galaxy
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Bounded, offroad-only previews through the normal sound owner."""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.audio.alert_volume import SPECS
|
||||
|
||||
ALERTS = {
|
||||
'WarningImmediateVolume': 'warningImmediate', 'WarningSoftVolume': 'warningSoft',
|
||||
'RefuseVolume': 'refuse', 'PromptDistractedVolume': 'promptDistracted',
|
||||
'EngageVolume': 'engage', 'DisengageVolume': 'disengage', 'PromptVolume': 'prompt',
|
||||
}
|
||||
|
||||
|
||||
class AlertPreviewError(Exception):
|
||||
def __init__(self, message, status=409):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class AlertPreview:
|
||||
def __init__(self, parked, runner=subprocess.run):
|
||||
self.parked, self.runner = parked, runner
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def play(self, payload):
|
||||
if type(payload) is not dict or set(payload) != {'key'} or type(payload['key']) is not str or payload['key'] not in SPECS:
|
||||
raise AlertPreviewError('Choose an alert volume to preview.', 400)
|
||||
if not self.parked():
|
||||
raise AlertPreviewError('Put the comma offroad before testing an alert.')
|
||||
if not self.lock.acquire(blocking=False):
|
||||
raise AlertPreviewError('An alert preview is already playing.')
|
||||
try:
|
||||
result = self.runner([sys.executable, '-m', 'openpilot.starpilot.audio.preview', payload['key']],
|
||||
cwd=Path(__file__).resolve().parents[3], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=8)
|
||||
if result.returncode == 2:
|
||||
raise AlertPreviewError('Preview stopped. Keep the comma offroad and close Driver Camera.')
|
||||
if result.returncode:
|
||||
raise AlertPreviewError('The comma could not play this alert. Try again.', 503)
|
||||
return {'played': True}
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise AlertPreviewError('The preview timed out. Try again.', 503) from error
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
|
||||
def play_saved_alert(key, *, params, context, sound, audio, alert, now=time.monotonic, sleep=time.sleep):
|
||||
if key not in ALERTS or not context.offroad() or params.get_bool('IsDriverViewEnabled'):
|
||||
raise AlertPreviewError('Offroad audio is unavailable.')
|
||||
context.evidence_wait_ms = 0
|
||||
sound.refresh_saved_volumes(now())
|
||||
sound.update_alert(alert)
|
||||
samples = sound.loaded_sounds[alert]
|
||||
duration = 2.0 if ALERTS[key] in ('warningSoft', 'warningImmediate', 'promptDistracted') else min(3.0, len(samples) / 48000)
|
||||
if not context.offroad():
|
||||
raise AlertPreviewError('Offroad audio is unavailable.')
|
||||
stream = sound.get_stream(audio)
|
||||
if not context.offroad() or params.get_bool('IsDriverViewEnabled'):
|
||||
stream.close()
|
||||
raise AlertPreviewError('Preview stopped before playback.')
|
||||
with stream:
|
||||
deadline = now() + duration
|
||||
while now() < deadline:
|
||||
if not context.offroad() or params.get_bool('IsDriverViewEnabled'):
|
||||
raise AlertPreviewError('Preview stopped.')
|
||||
sound.update_critical_sound(now())
|
||||
if sound.native_audio is not None:
|
||||
sound.native_audio.publish()
|
||||
sleep(.05)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in ALERTS:
|
||||
return 2
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.starpilot.galaxy.settings import LiveContextSource
|
||||
params = Params()
|
||||
context = LiveContextSource(params)
|
||||
path = Path(tempfile.gettempdir()) / f'starpilot-alert-preview-{os.getuid()}.lock'
|
||||
descriptor = None
|
||||
try:
|
||||
if not context.offroad() or params.get_bool('IsDriverViewEnabled'):
|
||||
return 2
|
||||
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
|
||||
return 2
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
from openpilot.selfdrive.ui.soundd import Soundd, AudibleAlert
|
||||
from openpilot.system import micd
|
||||
import sounddevice
|
||||
micd.patch_sounddevice(sounddevice)
|
||||
key = sys.argv[1]
|
||||
play_saved_alert(key, params=params, context=context, sound=Soundd(params), audio=sounddevice,
|
||||
alert=getattr(AudibleAlert, ALERTS[key]))
|
||||
return 0
|
||||
except (AlertPreviewError, BlockingIOError):
|
||||
return 2
|
||||
finally:
|
||||
context.close()
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from openpilot.starpilot.audio.alert_volume import SPECS
|
||||
from openpilot.starpilot.audio.preview import ALERTS, AlertPreview, AlertPreviewError, play_saved_alert
|
||||
|
||||
|
||||
class PreviewTests(unittest.TestCase):
|
||||
def test_all_volume_rows_have_a_real_alert(self):
|
||||
self.assertEqual(set(ALERTS), set(SPECS))
|
||||
|
||||
def test_saved_key_only_and_offroad_authorization(self):
|
||||
runner = Mock(return_value=SimpleNamespace(returncode=0))
|
||||
preview = AlertPreview(lambda: False, runner)
|
||||
for body in ({}, {'key': []}, {'key': 'bad'}, {'key': 'EngageVolume', 'volume': 100}):
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
preview.play(body)
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
preview.play({'key': 'EngageVolume'})
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_preview_uses_fixed_argv_and_reaps_with_bounded_run(self):
|
||||
runner = Mock(return_value=SimpleNamespace(returncode=0))
|
||||
preview = AlertPreview(lambda: True, runner)
|
||||
for key in SPECS:
|
||||
self.assertEqual(preview.play({'key': key}), {'played': True})
|
||||
args, kwargs = runner.call_args
|
||||
self.assertEqual(args[0][-2:], ['openpilot.starpilot.audio.preview', key])
|
||||
self.assertEqual(kwargs['timeout'], 8)
|
||||
self.assertIs(kwargs['stdin'], subprocess.DEVNULL)
|
||||
self.assertTrue(preview.lock.acquire(blocking=False))
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
preview.play({'key': 'EngageVolume'})
|
||||
preview.lock.release()
|
||||
|
||||
def test_errors_release_preview_ownership(self):
|
||||
for outcome in (SimpleNamespace(returncode=2), SimpleNamespace(returncode=1), subprocess.TimeoutExpired('preview', 8)):
|
||||
runner = Mock(side_effect=outcome) if isinstance(outcome, Exception) else Mock(return_value=outcome)
|
||||
preview = AlertPreview(lambda: True, runner)
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
preview.play({'key': 'EngageVolume'})
|
||||
self.assertTrue(preview.lock.acquire(blocking=False))
|
||||
preview.lock.release()
|
||||
|
||||
def test_worker_uses_normal_sound_stream_and_stops_when_offroad_evidence_is_lost(self):
|
||||
for revoked in (False, True):
|
||||
self._worker_stream_case(revoked)
|
||||
|
||||
def _worker_stream_case(self, revoked):
|
||||
clock = [0.0]
|
||||
stream = []
|
||||
context = SimpleNamespace(offroad=lambda: not revoked or clock[0] < .1, evidence_wait_ms=750)
|
||||
params = SimpleNamespace(get_bool=lambda _: False)
|
||||
|
||||
@contextmanager
|
||||
def audio_stream(_):
|
||||
stream.append('opened')
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stream.append('closed')
|
||||
|
||||
sound = SimpleNamespace(refresh_saved_volumes=Mock(), update_alert=Mock(),
|
||||
loaded_sounds={7: [0] * 48000}, get_stream=audio_stream, update_critical_sound=Mock(), native_audio=None)
|
||||
def sleep(seconds):
|
||||
clock[0] += seconds
|
||||
def call():
|
||||
return play_saved_alert('EngageVolume', params=params, context=context, sound=sound, audio=object(),
|
||||
alert=7, now=lambda: clock[0], sleep=sleep)
|
||||
if revoked:
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
call()
|
||||
else:
|
||||
call()
|
||||
self.assertEqual(stream, ['opened', 'closed'])
|
||||
sound.update_alert.assert_called_once_with(7)
|
||||
sound.refresh_saved_volumes.assert_called_once()
|
||||
self.assertLessEqual(clock[0], 1.05)
|
||||
|
||||
def test_driver_camera_prevents_any_audio_stream(self):
|
||||
sound = SimpleNamespace(get_stream=Mock())
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
play_saved_alert('EngageVolume', params=SimpleNamespace(get_bool=lambda _: True),
|
||||
context=SimpleNamespace(offroad=lambda: True), sound=sound, audio=None, alert=1)
|
||||
sound.get_stream.assert_not_called()
|
||||
|
||||
def test_state_is_rechecked_after_initializing_the_audio_stream(self):
|
||||
permissions = iter((True, True, False))
|
||||
stream = Mock()
|
||||
sound = SimpleNamespace(refresh_saved_volumes=Mock(), update_alert=Mock(),
|
||||
loaded_sounds={7: [0] * 48000}, get_stream=Mock(return_value=stream))
|
||||
with self.assertRaises(AlertPreviewError):
|
||||
play_saved_alert('EngageVolume', params=SimpleNamespace(get_bool=lambda _: False),
|
||||
context=SimpleNamespace(offroad=lambda: next(permissions)), sound=sound, audio=None, alert=7)
|
||||
stream.close.assert_called_once()
|
||||
@@ -17,6 +17,7 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.starpilot.galaxy.batch_routes import change_drive_state, delete_records, non_default_settings, preview_layout, preview_settings_recovery
|
||||
from openpilot.starpilot.audio.preview import AlertPreview, AlertPreviewError
|
||||
from openpilot.starpilot.galaxy.access import AccessStatus, default_owner
|
||||
from openpilot.starpilot.galaxy.auth import LocalSessions
|
||||
from openpilot.starpilot.galaxy.http_server import (_LocalHTTPHandler, _LocalHTTPServer, allowed_authority,
|
||||
@@ -99,7 +100,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
starport_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None,
|
||||
local_access=None, tmux_live=None, starport_logs=None, starport_log_sender=None, hotspot=None, toyota_tools=None,
|
||||
replay_client=None, replay_root=None,
|
||||
spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None, local_hostnames=None):
|
||||
spotify_setup=None, companion=None, phone_location=None, reverse_proxy=None, local_hostnames=None, sound_preview=None):
|
||||
from openpilot.starpilot.galaxy.local_hostnames import LocalHostnames
|
||||
hostname_source = local_hostnames if local_hostnames is not None else LocalHostnames(clock=clock)
|
||||
from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH
|
||||
@@ -252,6 +253,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
favorites_source = favorites
|
||||
favorites_lock = threading.Lock()
|
||||
sound_source = sounds
|
||||
alert_preview = sound_preview or AlertPreview(parked=parked)
|
||||
sounds_lock = threading.Lock()
|
||||
|
||||
def drive_owner():
|
||||
@@ -1413,7 +1415,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
return
|
||||
try:
|
||||
result = sound_owner().snapshot()
|
||||
except SoundDownloadError as error:
|
||||
except (SoundDownloadError, AlertPreviewError) as error:
|
||||
self.json(error.status, {'error': str(error)})
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
self.json(503, {'error': 'Sound packs are unavailable'})
|
||||
@@ -1680,7 +1682,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
'/api/models/active', '/api/models/preferences', '/api/models/download', '/api/models/download_all',
|
||||
'/api/models/cancel', '/api/models/delete', '/api/models/refresh_manifest', '/api/models/jetlink',
|
||||
'/api/models/laboratory', '/api/models/laboratory/download', '/api/models/laboratory/delete',
|
||||
'/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore',
|
||||
'/api/sounds/preview', '/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore',
|
||||
'/api/recordings/delete-videos', '/api/sentry/notifications', '/api/sentry/events/delete', '/api/crash-reports/delete',
|
||||
'/api/navigation/search', '/api/navigation/action', '/api/navigation/offline', '/api/drive-state/action',
|
||||
'/api/toyota-tools/action', '/api/vehicle-selection/preview', '/api/vehicle-selection/confirm'):
|
||||
@@ -2269,8 +2271,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
if self.settings_session() != identity:
|
||||
self.json(401, {'error': 'Sign in to Galaxy'})
|
||||
return
|
||||
result = sound_owner().action(path.rsplit('/', 1)[-1], payload)
|
||||
except SoundDownloadError as error:
|
||||
result = (alert_preview.play(payload) if path == '/api/sounds/preview' else
|
||||
sound_owner().action(path.rsplit('/', 1)[-1], payload))
|
||||
except (SoundDownloadError, AlertPreviewError) as error:
|
||||
self.json(error.status, {'error': str(error)})
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
self.json(503, {'error': 'Sound packs are unavailable'})
|
||||
|
||||
@@ -394,7 +394,8 @@ def _projection(row: FeatureRow) -> dict:
|
||||
"repairValue": row.repair_value, "defaultValue": row.default_value, "resetAvailable": row_default(row) is not None,
|
||||
"displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None,
|
||||
**({"coordinate": row.key.removeprefix("torque:surface:")} if row.key.startswith("torque:surface:") and row.key != "torque:surface:reset" else {}),
|
||||
"heading": is_heading(row.key)}
|
||||
"heading": is_heading(row.key),
|
||||
**({"previewSound": row.key} if row.key in SOUND_SPECS else {})}
|
||||
|
||||
|
||||
def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
|
||||
|
||||
@@ -239,3 +239,26 @@ for (const row of [{ value: "30", minimum: 1, maximum: 30, step: 1, unit: "h" },
|
||||
assert.match(template, /presentedRow\.reason && !row\.heading/)
|
||||
}
|
||||
console.log("Settings controls: section headings render as labels only")
|
||||
|
||||
|
||||
const previewVolume = control({ ...numeric, previewSound: "EngageVolume" })
|
||||
let finishPreview
|
||||
const playedKeys = []
|
||||
previewVolume.vm.testAlert = key => { playedKeys.push(key); return new Promise(resolve => { finishPreview = resolve }) }
|
||||
const playing = previewVolume.vm.previewSound()
|
||||
assert.equal(previewVolume.vm.testingSound, true)
|
||||
assert.deepEqual(playedKeys, ["EngageVolume"])
|
||||
await previewVolume.vm.previewSound()
|
||||
assert.equal(playedKeys.length, 1)
|
||||
finishPreview()
|
||||
await playing
|
||||
assert.equal(previewVolume.vm.testingSound, false)
|
||||
assert.deepEqual(previewVolume.commits, [])
|
||||
previewVolume.vm.testAlert = async () => { throw new Error("Keep the comma offroad") }
|
||||
await previewVolume.vm.previewSound()
|
||||
assert.equal(previewVolume.vm.soundError, "Keep the comma offroad")
|
||||
assert.equal(previewVolume.vm.testingSound, false)
|
||||
previewVolume.vm.disabled = true
|
||||
previewVolume.vm.testAlert = () => { throw new Error("disabled preview must not run") }
|
||||
await previewVolume.vm.previewSound()
|
||||
assert.equal(previewVolume.vm.soundError, "Keep the comma offroad")
|
||||
|
||||
@@ -1892,3 +1892,12 @@ progress { max-width: 100%; accent-color: var(--primary); }
|
||||
.gx-guided__link:hover:not(:disabled) { text-decoration: underline; }
|
||||
.gx-guided__link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.gx-guided__link:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.gx-slider-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-left: auto; }
|
||||
.gx-alert-preview { min-width: 118px; min-height: 44px; gap: 8px; white-space: nowrap; }
|
||||
.gx-alert-preview[aria-busy="true"] .bi { animation: gx-preview-pulse .7s ease-in-out infinite alternate; }
|
||||
.gx-alert-preview-note { margin: 0; font-size: var(--fs-xs); color: var(--on-surface-variant); line-height: 1.4; }
|
||||
@keyframes gx-preview-pulse { to { opacity: .35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .gx-alert-preview[aria-busy="true"] .bi { animation: none; } }
|
||||
.gx-slider-header--preview { align-items: center; flex-wrap: wrap; }
|
||||
.gx-slider-header .gx-slider-actions .gx-setting-default { margin: 0; }
|
||||
|
||||
@@ -67,9 +67,10 @@ export const GalaxySettingRow = {
|
||||
busy: { type: Boolean, default: false },
|
||||
feedback: { type: String, default: "saved" },
|
||||
saveValue: { type: Function, required: true },
|
||||
testAlert: { type: Function, default: null },
|
||||
},
|
||||
emits: ["open", "review", "reset-default"],
|
||||
data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false } },
|
||||
data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false, testingSound: false, soundError: "" } },
|
||||
computed: {
|
||||
presentedRow() { return presentedSetting(this.row) },
|
||||
control() { return settingControl(this.row) },
|
||||
@@ -94,6 +95,14 @@ export const GalaxySettingRow = {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async previewSound() {
|
||||
if (this.locked || this.testingSound || !this.testAlert || !this.row.previewSound) return
|
||||
this.testingSound = true
|
||||
this.soundError = ""
|
||||
try { await this.testAlert(this.row.previewSound) }
|
||||
catch (error) { this.soundError = error?.message || "The alert could not be played." }
|
||||
finally { this.testingSound = false }
|
||||
},
|
||||
async commit(value) {
|
||||
if (this.locked) return
|
||||
if (String(value) === String(this.presentedRow.value)) { this.preview = undefined; return }
|
||||
@@ -231,17 +240,23 @@ export const GalaxySettingRow = {
|
||||
<span class="gx-switch__track"></span><span class="gx-switch__thumb"></span>
|
||||
</label>
|
||||
<div v-else-if="control === 'slider'" class="gx-slider-row" :class="{ 'is-fine-scrubbing': isFineScrubbing }">
|
||||
<div class="gx-slider-header"><span class="gx-row__value">{{ displayValue }}</span>
|
||||
<div class="gx-slider-header" :class="{ 'gx-slider-header--preview': testAlert && row.previewSound }"><span class="gx-row__value">{{ displayValue }}</span>
|
||||
<span v-if="interacting" class="gx-slider-hint">{{ isFineScrubbing ? 'Fine scrubbing' : 'Hold to fine scrub' }}</span>
|
||||
<span class="gx-slider-actions">
|
||||
<button v-if="testAlert && row.previewSound" type="button" class="gx-btn gx-btn--tonal gx-alert-preview"
|
||||
:disabled="locked || testingSound" :aria-label="'Test ' + row.label + ' on your comma'" :aria-busy="testingSound"
|
||||
@click="previewSound"><i class="bi" :class="testingSound ? 'bi-soundwave' : 'bi-volume-up'" aria-hidden="true"></i>
|
||||
{{ testingSound ? 'Playing…' : 'Test alert' }}</button>
|
||||
<button v-if="showDefault" type="button"
|
||||
class="gx-btn gx-btn--tonal gx-setting-default" :disabled="dimmed || !row.resetAvailable"
|
||||
:aria-label="'Reset ' + row.label + ' to default'" :title="'Default: ' + presentedRow.defaultValue"
|
||||
@click="$emit('reset-default', index)">Default</button></div>
|
||||
@click="$emit('reset-default', index)">Default</button></span></div>
|
||||
<input ref="slider" type="range" class="gx-slider" :aria-label="row.label" :aria-valuetext="displayValue"
|
||||
:min="bounds.min" :max="bounds.max" :step="bounds.step" :value="sliderValue" :disabled="dimmed"
|
||||
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
|
||||
@pointerdown="onSliderPointerDown" @pointermove="onSliderPointerMove" @pointerup="onSliderPointerEnd"
|
||||
@pointercancel="onSliderCancel" @lostpointercapture="onSliderCancel" @keydown="beginInteract">
|
||||
<p v-if="soundError || testingSound" class="gx-alert-preview-note" role="status" aria-live="polite">{{ soundError || 'Playing on your comma…' }}</p>
|
||||
<div class="gx-slider-meta"><span>{{ bounds.min }} to {{ bounds.numericMax ?? bounds.max }} {{ presentedRow.unit }}<template v-if="bounds.auto"> · Auto at right</template></span><span>Step: {{ bounds.step }} {{ presentedRow.unit }}</span></div>
|
||||
</div>
|
||||
<GalaxySelect v-else-if="control === 'select'" class="gx-field" :aria-label="row.label" :value="currentValue" :disabled="dimmed" @change="onSelect">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestJson } from "./startup.js"
|
||||
import { GxDialog } from "./dialog.js"
|
||||
import { GxState } from "./state.js"
|
||||
import { connectionError } from "./polling.js"
|
||||
@@ -269,13 +270,13 @@ export const SettingsPage = {
|
||||
const navigation = route.view
|
||||
const navigationKey = `${props.initialPage}:${props.initialSection || ""}`
|
||||
const saved = navigation.settings?.key === navigationKey ? navigation.settings : null
|
||||
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 })
|
||||
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, testingSound: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 })
|
||||
if (saved) Object.assign(state, { section: saved.section, query: saved.query, searchOpen: saved.searchOpen, parents: saved.parents.slice(), developerOpen: saved.developerOpen })
|
||||
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
|
||||
return { state, feed, sections: SETTINGS_SECTIONS, navigation, navigationKey, restoredPage: saved?.page }
|
||||
},
|
||||
computed: {
|
||||
busy() { return settingsBusy(this.state) },
|
||||
busy() { return settingsBusy(this.state) || !!this.state.testingSound },
|
||||
activeSection() {
|
||||
if (this.state.developerOpen) return this.sections.at(-1)
|
||||
const page = (this.state.data?.page || "hub").split("/")[0]
|
||||
@@ -335,6 +336,18 @@ export const SettingsPage = {
|
||||
this.feed.stop()
|
||||
},
|
||||
methods: {
|
||||
async testAlert(key) {
|
||||
if (this.busy) return
|
||||
this.state.testingSound = true
|
||||
try {
|
||||
await requestJson("./api/sounds/preview", { timeout: 10000, request: {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }),
|
||||
} })
|
||||
} catch (error) {
|
||||
if (error?.status === 401) this.unauthorized()
|
||||
throw error
|
||||
} finally { this.state.testingSound = false }
|
||||
},
|
||||
open(page) {
|
||||
if (this.busy) return
|
||||
this.$emit?.("nested")
|
||||
@@ -426,7 +439,7 @@ export const SettingsPage = {
|
||||
</div>
|
||||
<p class="gx-page-description" v-if="state.developerOpen">Choose developer driving options and cloud services.</p>
|
||||
<p class="gx-page-description" v-else-if="initialPage === 'pip'">Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.</p>
|
||||
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.</p>
|
||||
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Test alert plays on your comma while offroad; Auto previews a quiet-cabin starting level. Immediate warnings keep their safety volume ramp.</p>
|
||||
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'display'">Adjust brightness and screen timing when custom display settings are on.</p>
|
||||
<p class="gx-page-description" v-else-if="initialPage === 'lane_change'">Configure lane-change assistance, signal timing, steering pace and following gap.</p>
|
||||
<p class="gx-page-description" v-else-if="state.data?.page === 'conditional' || state.data?.page.startsWith('conditional/')">Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.</p>
|
||||
@@ -464,7 +477,7 @@ export const SettingsPage = {
|
||||
</div>
|
||||
<div v-if="state.searchOpen && !cardIndex" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
|
||||
<GalaxySettingRow v-for="{ row, index } in card.entries" :key="rowKey(row, index)" :row="row" :index="index"
|
||||
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
|
||||
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)" :test-alert="state.data.page === 'sounds' ? testAlert : null"
|
||||
@open="open" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
|
||||
<div v-if="!cardIndex && !visibleRows.length" class="gx-empty">No matching settings.</div>
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "4c66eab4bc2ebf4cfba3329df36fac04a20c572295a7da22b03616013b3bd5d9", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "0a96cfa499ada974607b1255975f78be11f09ce5f3d1904e2a27598ca08e3d07", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user