feat: add playable flight and office screen sharing
This commit is contained in:
+275
-26
@@ -80,6 +80,16 @@ import {
|
||||
type ProfileEditor,
|
||||
} from "./profile/index.ts";
|
||||
import type { ActorIdentity } from "./actors/controller.ts";
|
||||
import { SRGBColorSpace, VideoTexture } from "three";
|
||||
import {
|
||||
CALIFORNIA_AIR_ROUTE,
|
||||
type AircraftActionSnapshot,
|
||||
} from "./aircraft/index.ts";
|
||||
import {
|
||||
createOfficeScreenPanel,
|
||||
type MediaSurfaceDescriptor,
|
||||
type OfficeScreenPanel,
|
||||
} from "./media/index.ts";
|
||||
/**
|
||||
* Three type-only imports and not one value among them, which is what keeps the
|
||||
* office and the instruments out of the entry chunk.
|
||||
@@ -422,6 +432,13 @@ let officeId = OFFICES[0]?.id ?? "lumbridge-hq";
|
||||
* interior rig and is a supported state rather than a gap.
|
||||
*/
|
||||
let officeAtmosphere: Atmosphere | null = null;
|
||||
let officeScreenPanel: OfficeScreenPanel | null = null;
|
||||
let sharedScreen: {
|
||||
screenId: string;
|
||||
stream: MediaStream;
|
||||
video: HTMLVideoElement;
|
||||
texture: VideoTexture;
|
||||
} | null = null;
|
||||
/**
|
||||
* The plan panel's other occupant.
|
||||
*
|
||||
@@ -728,6 +745,7 @@ async function mountCity(id: string) {
|
||||
poseEditor?.destroy();
|
||||
poseEditor = null;
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office?.dispose();
|
||||
@@ -868,6 +886,18 @@ async function mountCity(id: string) {
|
||||
count: 14,
|
||||
seed: 115,
|
||||
},
|
||||
aircraft: {
|
||||
route: CALIFORNIA_AIR_ROUTE,
|
||||
initialPosition: CALIFORNIA_AIR_ROUTE[0],
|
||||
initialAltitudeM: CALIFORNIA_AIR_ROUTE[0]?.altitudeM ?? 1_350,
|
||||
initialHeadingDeg: 320,
|
||||
assistedAltitudeM: 1_500,
|
||||
// Corridor altitude is an atlas glyph just like the black cars:
|
||||
// literal metres would put the chase camera inside the coarse hills.
|
||||
altitudeSceneUnitsPerMetre: 0.01,
|
||||
visualSceneUnitsPerMetre: 0.22,
|
||||
camera: { distance: 52, height: 18, lookAhead: 18 },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
flights: dial.source,
|
||||
@@ -1326,6 +1356,7 @@ function leaveOffice() {
|
||||
// Before the scene swap, so the last thing the watch can do is abort a request
|
||||
// rather than publish into a room the user has already left.
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
office?.walker?.setActive(false);
|
||||
office?.walker?.setAction({ x: 0, z: 0 });
|
||||
dispatchJourney({ type: "leave-office" });
|
||||
@@ -1491,9 +1522,12 @@ const credits = document.querySelector<HTMLElement>("#credits");
|
||||
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
|
||||
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
|
||||
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
|
||||
const flyButton = document.querySelector<HTMLButtonElement>("#fly");
|
||||
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
|
||||
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
||||
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
||||
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
||||
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
|
||||
let profileEditor: ProfileEditor | null = null;
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
@@ -1559,6 +1593,7 @@ async function switchOffice(id: string) {
|
||||
|
||||
// The roster belongs to the building you have left.
|
||||
stopWatchingOccupancy();
|
||||
disposeOfficeScreenUi();
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office?.dispose();
|
||||
@@ -1626,6 +1661,7 @@ function renderLegend() {
|
||||
}
|
||||
const walking = inside && (office?.walker?.active() ?? false);
|
||||
const exploring = !inside && (city.actorActive() ?? false);
|
||||
const flying = !inside && (city.aircraftActive() ?? false);
|
||||
if (walkButton) {
|
||||
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
|
||||
walkButton.setAttribute("aria-pressed", String(walking || exploring));
|
||||
@@ -1637,6 +1673,15 @@ function renderLegend() {
|
||||
walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor} →`;
|
||||
}
|
||||
}
|
||||
if (flyButton) {
|
||||
flyButton.hidden = inside || cityId !== "california" || city.aircraftState() === null;
|
||||
flyButton.setAttribute("aria-pressed", String(flying));
|
||||
flyButton.textContent = flying ? "Return to flyover ↑" : "Fly the California route →";
|
||||
}
|
||||
if (screensButton) {
|
||||
const canManage = inside && office?.depth === "full" && (office.listMediaSurfaces().length > 0);
|
||||
screensButton.hidden = !canManage;
|
||||
}
|
||||
renderSource();
|
||||
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
|
||||
if (canvas) {
|
||||
@@ -1646,6 +1691,8 @@ function renderLegend() {
|
||||
? walking
|
||||
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
|
||||
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
|
||||
: flying
|
||||
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
|
||||
: exploring
|
||||
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.`
|
||||
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
||||
@@ -1658,12 +1705,27 @@ function renderLegend() {
|
||||
officePlan?.setActiveView(office?.current() ?? null);
|
||||
renderOfficeBadge();
|
||||
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||
if (walkControls) walkControls.hidden = !(walking || exploring);
|
||||
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
|
||||
control.hidden = inside;
|
||||
if (walkControls) walkControls.hidden = !(walking || exploring || flying);
|
||||
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
|
||||
control.textContent = flying
|
||||
? control.dataset.aircraftLabel ?? control.textContent
|
||||
: control.dataset.walkLabel ?? control.textContent;
|
||||
}
|
||||
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
|
||||
control.hidden = inside || (!flying && city.actorState()?.kind !== "crow");
|
||||
}
|
||||
for (const control of walkControls?.querySelectorAll<HTMLElement>(".aircraft-only") ?? []) {
|
||||
control.hidden = !flying;
|
||||
}
|
||||
if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying;
|
||||
if (walkHint) {
|
||||
walkHint.hidden = !(inside || city.actorState() || city.aircraftState());
|
||||
walkHint.textContent = flying
|
||||
? "WASD fly · P assisted · R reset"
|
||||
: inside
|
||||
? "V walk · WASD move"
|
||||
: "V explore · WASD move";
|
||||
}
|
||||
if (driveHint) driveHint.hidden = inside || cityId !== "california";
|
||||
if (walkHint) walkHint.hidden = !(inside || city.actorState());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1800,7 +1862,10 @@ function renderOfficeBadge() {
|
||||
if (!publicOffice) {
|
||||
if (mediaSurfaces.length === 0) return;
|
||||
const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
|
||||
officeBadge.textContent = `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
|
||||
const active = mediaSurfaces.filter((surface) => surface.bound).length;
|
||||
officeBadge.textContent = active > 0
|
||||
? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.`
|
||||
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
|
||||
return;
|
||||
}
|
||||
officeBadge.replaceChildren(
|
||||
@@ -1863,23 +1928,20 @@ function renderTierBadge() {
|
||||
}
|
||||
|
||||
function applyProfilePreview(profile: LocalProfile): void {
|
||||
if (access.subject === null || inside) return;
|
||||
city?.setActorIdentity(signedInActorIdentity(profile));
|
||||
}
|
||||
|
||||
async function rebuildOfficeActor(): Promise<void> {
|
||||
if (!inside || !office) return;
|
||||
const wasWalking = office.walker?.active() ?? false;
|
||||
stopWatchingOccupancy();
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office.dispose();
|
||||
office = null;
|
||||
officeAtmosphere = null;
|
||||
await building("Updating your character…", () => enterOffice());
|
||||
const rebuilt = office as OfficeScene | null;
|
||||
if (wasWalking) rebuilt?.walker?.setActive(true);
|
||||
renderLegend();
|
||||
if (access.subject === null) return;
|
||||
if (inside) {
|
||||
const appearance = resolveHumanoidAppearance(profile.appearance);
|
||||
office?.walker?.setAppearance({
|
||||
kind: "humanoid",
|
||||
skinTone: appearance.skinTone,
|
||||
outfitColor: appearance.outfitColor,
|
||||
accentColor: appearance.accentColor,
|
||||
hairColor: appearance.hairColor,
|
||||
bodyShape: appearance.bodyShape,
|
||||
});
|
||||
} else {
|
||||
city?.setActorIdentity(signedInActorIdentity(profile));
|
||||
}
|
||||
}
|
||||
|
||||
function ensureProfileEditor(): ProfileEditor | null {
|
||||
@@ -1903,9 +1965,9 @@ function ensureProfileEditor(): ProfileEditor | null {
|
||||
},
|
||||
});
|
||||
city?.setActorIdentity(signedInActorIdentity(profile));
|
||||
applyProfilePreview(profile);
|
||||
renderTierBadge();
|
||||
profileOverlay.hidden = true;
|
||||
if (inside) void rebuildOfficeActor();
|
||||
},
|
||||
onCancel() {
|
||||
profileOverlay.hidden = true;
|
||||
@@ -1924,6 +1986,132 @@ function openProfileEditor(): void {
|
||||
editor.open();
|
||||
}
|
||||
|
||||
function stopLocalScreenShare(): void {
|
||||
const shared = sharedScreen;
|
||||
if (!shared) return;
|
||||
sharedScreen = null;
|
||||
office?.clearMediaSurface(shared.screenId);
|
||||
for (const track of shared.stream.getTracks()) track.stop();
|
||||
shared.texture.dispose();
|
||||
shared.video.pause();
|
||||
shared.video.srcObject = null;
|
||||
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
|
||||
renderOfficeBadge();
|
||||
}
|
||||
|
||||
function disposeOfficeScreenUi(): void {
|
||||
stopLocalScreenShare();
|
||||
officeScreenPanel?.dispose();
|
||||
officeScreenPanel = null;
|
||||
if (screensOverlay) screensOverlay.hidden = true;
|
||||
}
|
||||
|
||||
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
|
||||
if (!office || !inside || office.depth !== "full") return;
|
||||
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
||||
if (!optedIn) {
|
||||
showDetail("Opt in to view this screen before starting a local preview.");
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||
showDetail("This browser does not provide tab or window sharing.");
|
||||
return;
|
||||
}
|
||||
let pendingStream: MediaStream | null = null;
|
||||
let pendingVideo: HTMLVideoElement | null = null;
|
||||
let pendingTexture: VideoTexture | null = null;
|
||||
try {
|
||||
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { displaySurface: "browser" },
|
||||
audio: false,
|
||||
// Chromium honours these as chooser preferences. Other browsers ignore
|
||||
// unknown dictionary members and still require the same explicit prompt.
|
||||
monitorTypeSurfaces: "exclude",
|
||||
selfBrowserSurface: "exclude",
|
||||
surfaceSwitching: "include",
|
||||
} as DisplayMediaStreamOptions);
|
||||
pendingStream = stream;
|
||||
const video = document.createElement("video");
|
||||
pendingVideo = video;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
const texture = new VideoTexture(video);
|
||||
pendingTexture = texture;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
stopLocalScreenShare();
|
||||
if (!office?.bindMediaSurface(surface.screenId, { canView: true, optedIn: true }, texture)) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
texture.dispose();
|
||||
video.pause();
|
||||
video.srcObject = null;
|
||||
showDetail("That screen is no longer available.");
|
||||
return;
|
||||
}
|
||||
sharedScreen = { screenId: surface.screenId, stream, video, texture };
|
||||
pendingStream = null;
|
||||
pendingVideo = null;
|
||||
pendingTexture = null;
|
||||
stream.getVideoTracks()[0]?.addEventListener("ended", () => stopLocalScreenShare(), { once: true });
|
||||
officeScreenPanel?.update(office.listMediaSurfaces());
|
||||
renderOfficeBadge();
|
||||
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
|
||||
} catch (error) {
|
||||
for (const track of pendingStream?.getTracks() ?? []) track.stop();
|
||||
pendingTexture?.dispose();
|
||||
if (pendingVideo) {
|
||||
pendingVideo.pause();
|
||||
pendingVideo.srcObject = null;
|
||||
}
|
||||
if ((error as DOMException)?.name !== "NotAllowedError") {
|
||||
showDetail("The screen preview could not start. Try again from Office screens.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
|
||||
if (!screensOverlay || !inside || !office || office.depth !== "full") return null;
|
||||
if (officeScreenPanel) {
|
||||
officeScreenPanel.update(office.listMediaSurfaces());
|
||||
return officeScreenPanel;
|
||||
}
|
||||
officeScreenPanel = createOfficeScreenPanel({
|
||||
container: screensOverlay,
|
||||
surfaces: office.listMediaSurfaces(),
|
||||
onRequestShare: (surface) => { void startLocalScreenShare(surface); },
|
||||
onStopShare: () => stopLocalScreenShare(),
|
||||
onViewerOptIn(surface, optedIn) {
|
||||
if (!optedIn && sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
|
||||
},
|
||||
});
|
||||
const syncOverlay = () => queueMicrotask(() => {
|
||||
if (officeScreenPanel && !officeScreenPanel.state().open) screensOverlay.hidden = true;
|
||||
});
|
||||
officeScreenPanel.root.addEventListener("click", syncOverlay);
|
||||
officeScreenPanel.root.addEventListener("keydown", (event) => {
|
||||
event.stopPropagation();
|
||||
syncOverlay();
|
||||
});
|
||||
return officeScreenPanel;
|
||||
}
|
||||
|
||||
function openOfficeScreens(): void {
|
||||
const panel = ensureOfficeScreenPanel();
|
||||
if (!panel || !screensOverlay) return;
|
||||
screensOverlay.hidden = false;
|
||||
panel.open();
|
||||
}
|
||||
|
||||
screensButton?.addEventListener("click", openOfficeScreens);
|
||||
screensOverlay?.addEventListener("click", (event) => {
|
||||
if (event.target !== screensOverlay) return;
|
||||
officeScreenPanel?.close();
|
||||
screensOverlay.hidden = true;
|
||||
});
|
||||
|
||||
// ---- Navigation -------------------------------------------------------------
|
||||
|
||||
/** The views on offer right now — city chapters, or office viewpoints inside. */
|
||||
@@ -2064,6 +2252,10 @@ function toggleOfficeWalk(): boolean {
|
||||
const active = !city.actorActive();
|
||||
city.setActorActive(active);
|
||||
if (!active) city.setActorActions({});
|
||||
if (active && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
}
|
||||
renderLegend();
|
||||
return true;
|
||||
}
|
||||
@@ -2072,12 +2264,31 @@ function toggleOfficeWalk(): boolean {
|
||||
const active = !walker.active();
|
||||
walker.setActive(active);
|
||||
if (!active) walker.setAction({ x: 0, z: 0 });
|
||||
if (active && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
}
|
||||
renderLegend();
|
||||
return true;
|
||||
}
|
||||
|
||||
walkButton?.addEventListener("click", () => toggleOfficeWalk());
|
||||
|
||||
function toggleAircraft(): boolean {
|
||||
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
|
||||
const active = !city.aircraftActive();
|
||||
city.setAircraftActive(active);
|
||||
if (!active) city.setAircraftActions({});
|
||||
if (active && window.innerWidth <= 600) {
|
||||
panelOpen = false;
|
||||
applyPanel();
|
||||
}
|
||||
renderLegend();
|
||||
return true;
|
||||
}
|
||||
|
||||
flyButton?.addEventListener("click", () => toggleAircraft());
|
||||
|
||||
/**
|
||||
* Clicking a building on the city walks into it.
|
||||
*
|
||||
@@ -2198,7 +2409,8 @@ const heldDriveKeys = new Set<string>();
|
||||
|
||||
function routeDriveIsActive(): boolean {
|
||||
const state = !inside ? city?.vehicleState() : null;
|
||||
return state !== null && state !== undefined && !city?.actorActive() && city?.current() === state.routeId;
|
||||
return state !== null && state !== undefined && !city?.actorActive() &&
|
||||
!city?.aircraftActive() && city?.current() === state.routeId;
|
||||
}
|
||||
|
||||
function publishVehicleActions(
|
||||
@@ -2244,6 +2456,21 @@ function publishCityActorActions(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function publishAircraftActions(
|
||||
supplement: Partial<AircraftActionSnapshot> = {},
|
||||
): boolean {
|
||||
if (inside || !city?.aircraftActive()) return false;
|
||||
city.setAircraftActions({
|
||||
throttle: heldDriveKeys.has(" ") ? 1 : 0,
|
||||
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
|
||||
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
|
||||
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
|
||||
modeRequest: supplement.modeRequest ?? "none",
|
||||
reset: supplement.reset ?? false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function toggleVehicleCamera(): boolean {
|
||||
if (!routeDriveIsActive() || !city) return false;
|
||||
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
|
||||
@@ -2278,6 +2505,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
|
||||
heldDriveKeys.delete(key);
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
publishOfficeWalkActions();
|
||||
publishAircraftActions();
|
||||
publishCityActorActions();
|
||||
event.preventDefault();
|
||||
};
|
||||
@@ -2286,6 +2514,7 @@ for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-wa
|
||||
heldDriveKeys.add(key);
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
publishOfficeWalkActions();
|
||||
publishAircraftActions();
|
||||
publishCityActorActions();
|
||||
event.preventDefault();
|
||||
});
|
||||
@@ -2300,17 +2529,25 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
|
||||
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
|
||||
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
|
||||
?.addEventListener("click", () => toggleVehicleCamera());
|
||||
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
|
||||
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
|
||||
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
|
||||
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
if (!heldDriveKeys.delete(key)) return;
|
||||
if (publishVehicleActions() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault();
|
||||
if (
|
||||
publishVehicleActions() || publishOfficeWalkActions() ||
|
||||
publishAircraftActions() || publishCityActorActions()
|
||||
) event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
heldDriveKeys.clear();
|
||||
publishVehicleActions();
|
||||
publishOfficeWalkActions();
|
||||
publishAircraftActions();
|
||||
publishCityActorActions();
|
||||
});
|
||||
|
||||
@@ -2394,6 +2631,10 @@ window.addEventListener("keydown", (event) => {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (publishAircraftActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (publishCityActorActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -2403,10 +2644,18 @@ window.addEventListener("keydown", (event) => {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "r" && publishVehicleActions({ reset: true })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "r" && publishAircraftActions({ reset: true })) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (lower === "c" && toggleVehicleCamera()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user