350 lines
9.6 KiB
Vue
350 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
watch,
|
|
} from "vue";
|
|
import IconGlyph, { type IconGlyphName } from "./IconGlyph.vue";
|
|
import type { AudioUiState } from "@/audio/types";
|
|
import type { MotionPolicy, QualityTier } from "@/journey/motion-policy";
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
policy: MotionPolicy;
|
|
maximumQuality: QualityTier;
|
|
sceneStatus: string;
|
|
audioState?: AudioUiState;
|
|
audioMuted?: boolean;
|
|
audioVolume?: number;
|
|
}>(),
|
|
{
|
|
audioState: () => ({ kind: "idle" }),
|
|
audioMuted: false,
|
|
audioVolume: 0.4,
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
pause: [];
|
|
resume: [];
|
|
enable: [];
|
|
qualityChange: [quality: QualityTier];
|
|
audioStart: [];
|
|
audioResume: [];
|
|
audioMuteToggle: [];
|
|
audioVolumeChange: [volume: number];
|
|
}>();
|
|
|
|
const controlRoot = ref<HTMLElement | null>(null);
|
|
const settingsButton = ref<HTMLButtonElement | null>(null);
|
|
const settingsPanel = ref<HTMLElement | null>(null);
|
|
const settingsOpen = ref(false);
|
|
|
|
const action = computed(() => {
|
|
if (props.policy.reason === "visitor-pause") {
|
|
return {
|
|
label: "Resume motion",
|
|
event: "resume" as const,
|
|
icon: "play" as IconGlyphName,
|
|
};
|
|
}
|
|
if (props.policy.reason === "saved-static") {
|
|
return {
|
|
label: "Enable motion",
|
|
event: "enable" as const,
|
|
icon: "play" as IconGlyphName,
|
|
};
|
|
}
|
|
if (props.policy.reason === "full") {
|
|
return {
|
|
label: "Pause motion",
|
|
event: "pause" as const,
|
|
icon: "pause" as IconGlyphName,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const isAudioActive = computed(
|
|
() =>
|
|
props.audioState.kind === "running" ||
|
|
props.audioState.kind === "suspended",
|
|
);
|
|
const muteLabel = computed(() =>
|
|
props.audioMuted ? "Unmute sound" : "Mute sound",
|
|
);
|
|
const muteIcon = computed<IconGlyphName>(() =>
|
|
props.audioMuted ? "sound-muted" : "sound",
|
|
);
|
|
|
|
const statusText = computed(() => {
|
|
if (props.policy.reason === "system-reduce")
|
|
return "Motion: static; reduced motion requested";
|
|
if (props.policy.reason === "data-saver")
|
|
return "Motion: static; data saver is active";
|
|
if (props.policy.reason === "visitor-pause") return "Motion: paused";
|
|
if (props.policy.reason === "saved-static") return "Motion: static";
|
|
if (props.sceneStatus === "failed" || props.sceneStatus === "static")
|
|
return "Visuals unavailable";
|
|
if (props.sceneStatus === "loading" || props.sceneStatus === "idle")
|
|
return "Motion: preparing";
|
|
return null;
|
|
});
|
|
|
|
const runAction = (): void => {
|
|
const event = action.value?.event;
|
|
if (event === "pause") emit("pause");
|
|
else if (event === "resume") emit("resume");
|
|
else if (event === "enable") emit("enable");
|
|
};
|
|
|
|
const changeQuality = (event: Event): void => {
|
|
const value = (event.currentTarget as HTMLSelectElement).value;
|
|
if (
|
|
value === "high" ||
|
|
value === "medium" ||
|
|
value === "low" ||
|
|
value === "static"
|
|
) {
|
|
emit("qualityChange", value);
|
|
}
|
|
};
|
|
|
|
const changeVolume = (event: Event): void => {
|
|
emit(
|
|
"audioVolumeChange",
|
|
Number((event.currentTarget as HTMLInputElement).value),
|
|
);
|
|
};
|
|
|
|
const toggleSettings = (): void => {
|
|
settingsOpen.value = !settingsOpen.value;
|
|
};
|
|
|
|
const closePanels = (restoreFocus = false): void => {
|
|
const wasOpen = settingsOpen.value;
|
|
settingsOpen.value = false;
|
|
if (restoreFocus && wasOpen) void nextTick(() => settingsButton.value?.focus());
|
|
};
|
|
|
|
/** Tab and Shift+Tab cycle inside the open settings panel and its trigger. */
|
|
const trapFocus = (event: KeyboardEvent): void => {
|
|
if (event.key !== "Tab" || !settingsOpen.value) return;
|
|
const panel = settingsPanel.value;
|
|
if (!panel || !settingsButton.value) return;
|
|
const stops = [
|
|
settingsButton.value,
|
|
...panel.querySelectorAll<HTMLElement>("button, input, select"),
|
|
];
|
|
const index = stops.indexOf(document.activeElement as HTMLElement);
|
|
if (index === -1) return;
|
|
const next = (index + (event.shiftKey ? -1 : 1) + stops.length) % stops.length;
|
|
event.preventDefault();
|
|
stops[next]?.focus();
|
|
};
|
|
|
|
const onDocumentPointerDown = (event: Event): void => {
|
|
if (controlRoot.value?.contains(event.target as Node)) return;
|
|
closePanels();
|
|
};
|
|
|
|
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
|
trapFocus(event);
|
|
if (event.key !== "Escape" || !settingsOpen.value) return;
|
|
event.preventDefault();
|
|
closePanels(true);
|
|
};
|
|
|
|
// Opening the panel moves focus into it; the trap keeps it there.
|
|
watch(settingsOpen, (open) => {
|
|
if (open)
|
|
void nextTick(() =>
|
|
settingsPanel.value
|
|
?.querySelector<HTMLElement>("input, button, select")
|
|
?.focus(),
|
|
);
|
|
});
|
|
|
|
watch(
|
|
() => props.audioState.kind,
|
|
() => closePanels(),
|
|
);
|
|
|
|
onMounted(() => {
|
|
document.addEventListener("pointerdown", onDocumentPointerDown);
|
|
document.addEventListener("keydown", onDocumentKeyDown);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener("pointerdown", onDocumentPointerDown);
|
|
document.removeEventListener("keydown", onDocumentKeyDown);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="controlRoot"
|
|
class="drive-controls"
|
|
:data-drive-state="audioState.kind"
|
|
:data-muted="audioMuted"
|
|
>
|
|
<!-- Two monochrome bay buttons: the soundtrack (an equaliser that moves
|
|
while it plays) and the settings. Each is a 44 px target drawn as a
|
|
36 px tile. -->
|
|
<button
|
|
v-if="audioState.kind === 'idle'"
|
|
class="drive-start drive-control-button"
|
|
type="button"
|
|
aria-label="Play soundtrack"
|
|
data-tooltip="Play soundtrack"
|
|
@click="emit('audioStart')"
|
|
>
|
|
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
<span class="visually-hidden">PLAY SOUNDTRACK</span>
|
|
</button>
|
|
|
|
<button
|
|
v-else-if="audioState.kind === 'starting'"
|
|
class="drive-start drive-control-button"
|
|
type="button"
|
|
disabled
|
|
aria-label="Starting drive"
|
|
aria-live="polite"
|
|
>
|
|
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
<span class="visually-hidden">STARTING…</span>
|
|
</button>
|
|
|
|
<p
|
|
v-else-if="audioState.kind === 'failed'"
|
|
class="drive-controls__audio-status"
|
|
role="status"
|
|
>
|
|
Audio unavailable. Continue without sound.
|
|
</p>
|
|
|
|
<div
|
|
v-else-if="isAudioActive"
|
|
class="drive-controls__dock"
|
|
role="group"
|
|
aria-label="Drive controls"
|
|
>
|
|
<button
|
|
v-if="
|
|
audioState.kind === 'suspended' && audioState.resumeRequiresGesture
|
|
"
|
|
class="drive-control-button"
|
|
type="button"
|
|
aria-label="Resume sound"
|
|
title="Resume sound"
|
|
data-tooltip="Resume sound"
|
|
@click="emit('audioResume')"
|
|
>
|
|
<IconGlyph name="play" />
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="drive-control-button drive-sound"
|
|
type="button"
|
|
:aria-label="muteLabel"
|
|
:title="muteLabel"
|
|
:data-tooltip="muteLabel"
|
|
@click="emit('audioMuteToggle')"
|
|
>
|
|
<span class="drive-eq" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
v-if="action && action.event !== 'pause'"
|
|
class="drive-control-button drive-controls__action"
|
|
type="button"
|
|
:aria-label="action.label"
|
|
:title="action.label"
|
|
:data-tooltip="action.label"
|
|
@click="runAction"
|
|
>
|
|
<IconGlyph :name="action.icon" />
|
|
</button>
|
|
|
|
<button
|
|
v-if="audioState.kind !== 'starting'"
|
|
ref="settingsButton"
|
|
class="drive-control-button drive-controls__settings-trigger"
|
|
type="button"
|
|
aria-label="Drive settings"
|
|
title="Drive settings"
|
|
data-tooltip="Drive settings"
|
|
aria-controls="drive-settings-panel"
|
|
:aria-expanded="settingsOpen"
|
|
@click="toggleSettings"
|
|
>
|
|
<IconGlyph name="settings" />
|
|
</button>
|
|
|
|
<p
|
|
v-if="statusText"
|
|
class="drive-controls__status drive-controls__announcer visually-hidden"
|
|
role="status"
|
|
>
|
|
{{ statusText }}
|
|
</p>
|
|
|
|
<div
|
|
v-if="settingsOpen"
|
|
id="drive-settings-panel"
|
|
ref="settingsPanel"
|
|
class="drive-popover drive-popover--settings"
|
|
role="dialog"
|
|
aria-label="Drive settings"
|
|
>
|
|
<strong>DRIVE SETTINGS</strong>
|
|
<template v-if="isAudioActive">
|
|
<label for="drive-volume">SOUND</label>
|
|
<div class="drive-popover__volume">
|
|
<input
|
|
id="drive-volume"
|
|
aria-label="Sound volume level"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
:value="audioVolume"
|
|
:aria-valuetext="`${Math.round(audioVolume * 100)}%`"
|
|
@input="changeVolume"
|
|
/>
|
|
<!-- A plain readout: <output> is an implicit live region and would
|
|
compete with the motion status announcer. -->
|
|
<span class="drive-popover__readout" aria-hidden="true">{{ Math.round(audioVolume * 100) }}%</span>
|
|
</div>
|
|
</template>
|
|
<button
|
|
v-if="action && action.event === 'pause'"
|
|
class="drive-popover__action"
|
|
type="button"
|
|
:aria-label="action.label"
|
|
@click="runAction"
|
|
>
|
|
<IconGlyph :name="action.icon" />
|
|
<span aria-hidden="true">PAUSE MOTION</span>
|
|
</button>
|
|
<label for="drive-quality">QUALITY</label>
|
|
<select
|
|
id="drive-quality"
|
|
:value="maximumQuality"
|
|
aria-label="Quality control"
|
|
@change="changeQuality"
|
|
>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
<option value="static">Static</option>
|
|
</select>
|
|
<p v-if="statusText" class="drive-controls__status">{{ statusText }}</p>
|
|
</div>
|
|
</div>
|
|
</template>
|