"use strict"; /*jslint vars:true, plusplus:true, forin:true*/ /*global Tablet, Script, */ /* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */ // // spectatorCamera.js // // Created by Zach Fox on 2017-06-05 // Copyright 2017 High Fidelity, Inc // // Distributed under the Apache License, Version 2.0 // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // (function () { // BEGIN LOCAL_SCOPE // FUNCTION VAR DECLARATIONS var sendToQml, addOrRemoveButton, onTabletScreenChanged, fromQml, onTabletButtonClicked, wireEventBridge, startup, shutdown, registerButtonMappings; // Function Name: inFrontOf() // // Description: // -Returns the position in front of the given "position" argument, where the forward vector is based off // the "orientation" argument and the amount in front is based off the "distance" argument. function inFrontOf(distance, position, orientation) { return Vec3.sum(position || MyAvatar.position, Vec3.multiply(distance, Quat.getForward(orientation || MyAvatar.orientation))); } // Function Name: spectatorCameraOn() // // Description: // -Call this function to set up the spectator camera and // spawn the camera entity. // // Relevant Variables: // -spectatorCameraConfig: The render configuration of the spectator camera // render job. It controls various attributes of the Secondary Camera, such as: // -The entity ID to follow // -Position // -Orientation // -Rendered texture size // -Vertical field of view // -Near clip plane distance // -Far clip plane distance // -viewFinderOverlay: The in-world overlay that displays the spectator camera's view. // -camera: The in-world entity that corresponds to the spectator camera. // -cameraRotation: The rotation of the spectator camera. // -cameraPosition: The position of the spectator camera. // -glassPaneWidth: The width of the glass pane above the spectator camera that holds the viewFinderOverlay. // -viewFinderOverlayDim: The x, y, and z dimensions of the viewFinderOverlay. // -camera: The camera model which is grabbable. // -viewFinderOverlay: The preview of what the spectator camera is viewing, placed inside the glass pane. var spectatorCameraConfig = Render.getConfig("SecondaryCamera"); var viewFinderOverlay = false; var camera = false; var cameraRotation; var cameraPosition; var glassPaneWidth = 0.16; // The negative y dimension for viewFinderOverlay is necessary for now due to the way Image3DOverlay // draws textures, but should be looked into at some point. Also the z dimension shouldn't affect // the overlay since it is an Image3DOverlay so it is set to 0. var viewFinderOverlayDim = { x: glassPaneWidth, y: -glassPaneWidth, z: 0 }; function spectatorCameraOn() { // Sets the special texture size based on the window it is displayed in, which doesn't include the menu bar spectatorCameraConfig.enableSecondaryCameraRenderConfigs(true); spectatorCameraConfig.resetSizeSpectatorCamera(Window.innerWidth, Window.innerHeight); cameraRotation = Quat.multiply(MyAvatar.orientation, Quat.fromPitchYawRollDegrees(15, -155, 0)), cameraPosition = inFrontOf(0.85, Vec3.sum(MyAvatar.position, { x: 0, y: 0.28, z: 0 })); camera = Entities.addEntity({ "angularDamping": 0.95, "damping": 0.95, "collidesWith": "static,dynamic,kinematic,", "collisionMask": 7, "dynamic": false, "modelURL": Script.resolvePath("spectator-camera.fbx"), "name": "Spectator Camera", "registrationPoint": { "x": 0.56, "y": 0.545, "z": 0.23 }, "rotation": cameraRotation, "position": cameraPosition, "shapeType": "simple-compound", "type": "Model", "userData": "{\"grabbableKey\":{\"grabbable\":true}}", "isVisibleInSecondaryCamera": false }, true); spectatorCameraConfig.attachedEntityId = camera; updateOverlay(); if (!HMD.active) { setMonitorShowsCameraView(false); } else { setDisplay(monitorShowsCameraView); } // Change button to active when window is first opened OR if the camera is on, false otherwise. if (button) { button.editProperties({ isActive: onSpectatorCameraScreen || camera }); } Audio.playSound(SOUND_CAMERA_ON, { volume: 0.15, position: cameraPosition, localOnly: true }); // Remove the existing camera model from the domain if one exists. // It's easy for this to happen if the user crashes while the Spectator Camera is on. // We do this down here (after the new one is rezzed) so that we don't accidentally delete // the newly-rezzed model. var entityIDs = Entities.findEntitiesByName("Spectator Camera", MyAvatar.position, 100, false); entityIDs.forEach(function (currentEntityID) { var currentEntityOwner = Entities.getEntityProperties(currentEntityID, ['owningAvatarID']).owningAvatarID; if (currentEntityOwner === MyAvatar.sessionUUID && currentEntityID !== camera) { Entities.deleteEntity(currentEntityID); } }); } // Function Name: spectatorCameraOff() // // Description: // -Call this function to shut down the spectator camera and // destroy the camera entity. "isChangingDomains" is true when this function is called // from the "Window.domainChanged()" signal. var WAIT_AFTER_DOMAIN_SWITCH_BEFORE_CAMERA_DELETE_MS = 1 * 1000; function spectatorCameraOff(isChangingDomains) { function deleteCamera() { if (flash) { Entities.deleteEntity(flash); flash = false; } if (camera) { Entities.deleteEntity(camera); camera = false; } if (button) { // Change button to active when window is first openend OR if the camera is on, false otherwise. button.editProperties({ isActive: onSpectatorCameraScreen || camera }); } } spectatorCameraConfig.attachedEntityId = false; spectatorCameraConfig.enableSecondaryCameraRenderConfigs(false); if (camera) { // Workaround for Avatar Entities not immediately having properties after // the "Window.domainChanged()" signal is emitted. // Should be removed after FB6155 is fixed. if (isChangingDomains) { Script.setTimeout(function () { deleteCamera(); spectatorCameraOn(); }, WAIT_AFTER_DOMAIN_SWITCH_BEFORE_CAMERA_DELETE_MS); } else { deleteCamera(); } } if (viewFinderOverlay) { Overlays.deleteOverlay(viewFinderOverlay); } viewFinderOverlay = false; setDisplay(monitorShowsCameraView); } // Function Name: addOrRemoveButton() // // Description: // -Used to add or remove the "SPECTATOR" app button from the HUD/tablet. Set the "isShuttingDown" argument // to true if you're calling this function upon script shutdown. Set the "isHMDmode" to true if the user is // in HMD; otherwise set to false. // // Relevant Variables: // -button: The tablet button. // -buttonName: The name of the button. var button = false; var buttonName = "SPECTATOR"; function addOrRemoveButton(isShuttingDown) { if (!tablet) { print("Warning in addOrRemoveButton(): 'tablet' undefined!"); return; } if (!button) { if (!isShuttingDown) { button = tablet.addButton({ text: buttonName, icon: "icons/tablet-icons/spectator-i.svg", activeIcon: "icons/tablet-icons/spectator-a.svg" }); button.clicked.connect(onTabletButtonClicked); } } else if (button) { if (isShuttingDown) { button.clicked.disconnect(onTabletButtonClicked); tablet.removeButton(button); button = false; } } else { print("ERROR adding/removing Spectator button!"); } } // Function Name: startup() // // Description: // -startup() will be called when the script is loaded. // // Relevant Variables: // -tablet: The tablet instance to be modified. var tablet = null; function startup() { tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); addOrRemoveButton(false); tablet.screenChanged.connect(onTabletScreenChanged); Window.domainChanged.connect(onDomainChanged); Window.geometryChanged.connect(resizeViewFinderOverlay); Window.stillSnapshotTaken.connect(onStillSnapshotTaken); Window.snapshot360Taken.connect(on360SnapshotTaken); Controller.keyPressEvent.connect(keyPressEvent); HMD.displayModeChanged.connect(onHMDChanged); viewFinderOverlay = false; camera = false; registerButtonMappings(); } // Function Name: wireEventBridge() // // Description: // -Used to connect/disconnect the script's response to the tablet's "fromQml" signal. Set the "on" argument to enable or // disable to event bridge. // // Relevant Variables: // -hasEventBridge: true/false depending on whether we've already connected the event bridge. var hasEventBridge = false; function wireEventBridge(on) { if (!tablet) { print("Warning in wireEventBridge(): 'tablet' undefined!"); return; } if (on) { if (!hasEventBridge) { tablet.fromQml.connect(fromQml); hasEventBridge = true; } } else { if (hasEventBridge) { tablet.fromQml.disconnect(fromQml); hasEventBridge = false; } } } // Function Name: setDisplay() // // Description: // -There are two bool variables that determine what the "url" argument to "setDisplayTexture(url)" should be: // Camera on/off switch, and the "Monitor Shows" on/off switch. // This results in four possible cases for the argument. Those four cases are: // 1. Camera is off; "Monitor Shows" is "HMD Preview": "url" is "" // 2. Camera is off; "Monitor Shows" is "Camera View": "url" is "" // 3. Camera is on; "Monitor Shows" is "HMD Preview": "url" is "" // 4. Camera is on; "Monitor Shows" is "Camera View": "url" is "resource://spectatorCameraFrame" function setDisplay(showCameraView) { var url = (camera) ? (showCameraView ? "resource://spectatorCameraFrame" : "resource://hmdPreviewFrame") : ""; // FIXME: temporary hack to avoid setting the display texture to hmdPreviewFrame // until it is the correct mono. if (url === "resource://hmdPreviewFrame") { Window.setDisplayTexture(""); } else { Window.setDisplayTexture(url); } } const MONITOR_SHOWS_CAMERA_VIEW_DEFAULT = false; var monitorShowsCameraView = !!Settings.getValue('spectatorCamera/monitorShowsCameraView', MONITOR_SHOWS_CAMERA_VIEW_DEFAULT); function setMonitorShowsCameraView(showCameraView) { setDisplay(showCameraView); monitorShowsCameraView = showCameraView; Settings.setValue('spectatorCamera/monitorShowsCameraView', showCameraView); } function setMonitorShowsCameraViewAndSendToQml(showCameraView) { setMonitorShowsCameraView(showCameraView); sendToQml({ method: 'updateMonitorShowsSwitch', params: showCameraView }); } function keyPressEvent(event) { if ((event.text === "0") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && event.isControl && !event.isAlt) { setMonitorShowsCameraViewAndSendToQml(!monitorShowsCameraView); } } function updateOverlay() { // The only way I found to update the viewFinderOverlay without turning the spectator camera on and off is to delete and recreate the // overlay, which is inefficient but resizing the window shouldn't be performed often if (viewFinderOverlay) { Overlays.deleteOverlay(viewFinderOverlay); } viewFinderOverlay = Overlays.addOverlay("image3d", { url: "resource://spectatorCameraFrame", emissive: true, parentID: camera, alpha: 1, localRotation: { w: 1, x: 0, y: 0, z: 0 }, localPosition: { x: 0, y: 0.13, z: 0.126 }, dimensions: viewFinderOverlayDim }); } // Function Name: resizeViewFinderOverlay() // // Description: // -A function called when the window is moved/resized, which changes the viewFinderOverlay's texture and dimensions to be // appropriately altered to fit inside the glass pane while not distorting the texture. The "geometryChanged" argument gives information // on how the window changed, including x, y, width, and height. // // Relevant Variables: // -glassPaneRatio: The aspect ratio of the glass pane, currently set as a 16:9 aspect ratio (change if model changes). // -verticalScale: The amount the viewFinderOverlay should be scaled if the window size is vertical. // -squareScale: The amount the viewFinderOverlay should be scaled if the window size is not vertical but is more square than the // glass pane's aspect ratio. function resizeViewFinderOverlay(geometryChanged) { var glassPaneRatio = 16 / 9; var verticalScale = 1 / glassPaneRatio; var squareScale = verticalScale * (1 + (1 - (1 / (geometryChanged.width / geometryChanged.height)))); if (geometryChanged.height > geometryChanged.width) { //vertical window size viewFinderOverlayDim = { x: (glassPaneWidth * verticalScale), y: (-glassPaneWidth * verticalScale), z: 0 }; } else if ((geometryChanged.width / geometryChanged.height) < glassPaneRatio) { //square-ish window size, in-between vertical and horizontal viewFinderOverlayDim = { x: (glassPaneWidth * squareScale), y: (-glassPaneWidth * squareScale), z: 0 }; } else { //horizontal window size viewFinderOverlayDim = { x: glassPaneWidth, y: -glassPaneWidth, z: 0 }; } updateOverlay(); // if secondary camera is currently being used for mirror projection then don't update it's aspect ratio (will be done in spectatorCameraOn) if (!spectatorCameraConfig.mirrorProjection) { spectatorCameraConfig.resetSizeSpectatorCamera(geometryChanged.width, geometryChanged.height); } setDisplay(monitorShowsCameraView); } const SWITCH_VIEW_FROM_CONTROLLER_DEFAULT = false; var switchViewFromController = !!Settings.getValue('spectatorCamera/switchViewFromController', SWITCH_VIEW_FROM_CONTROLLER_DEFAULT); function setSwitchViewControllerMappingStatus(status) { if (!switchViewControllerMapping) { return; } if (status) { switchViewControllerMapping.enable(); } else { switchViewControllerMapping.disable(); } } function setSwitchViewFromController(setting) { if (setting === switchViewFromController) { return; } switchViewFromController = setting; setSwitchViewControllerMappingStatus(switchViewFromController); Settings.setValue('spectatorCamera/switchViewFromController', setting); } const TAKE_SNAPSHOT_FROM_CONTROLLER_DEFAULT = false; var takeSnapshotFromController = !!Settings.getValue('spectatorCamera/takeSnapshotFromController', TAKE_SNAPSHOT_FROM_CONTROLLER_DEFAULT); function setTakeSnapshotControllerMappingStatus(status) { if (!takeSnapshotControllerMapping) { return; } if (status) { takeSnapshotControllerMapping.enable(); } else { takeSnapshotControllerMapping.disable(); } } function setTakeSnapshotFromController(setting) { if (setting === takeSnapshotFromController) { return; } takeSnapshotFromController = setting; setTakeSnapshotControllerMappingStatus(takeSnapshotFromController); Settings.setValue('spectatorCamera/takeSnapshotFromController', setting); } // Function Name: registerButtonMappings() // // Description: // -Updates controller button mappings for Spectator Camera. // // Relevant Variables: // -switchViewControllerMappingName: The name of the controller mapping. // -switchViewControllerMapping: The controller mapping itself. // -takeSnapshotControllerMappingName: The name of the controller mapping. // -takeSnapshotControllerMapping: The controller mapping itself. // -controllerType: "OculusTouch", "Vive", "Other". var switchViewControllerMapping; var switchViewControllerMappingName = 'Hifi-SpectatorCamera-Mapping-SwitchView'; function registerSwitchViewControllerMapping() { switchViewControllerMapping = Controller.newMapping(switchViewControllerMappingName); if (controllerType === "OculusTouch") { switchViewControllerMapping.from(Controller.Standard.LS).to(function (value) { if (value === 1.0) { setMonitorShowsCameraViewAndSendToQml(!monitorShowsCameraView); } return; }); } else if (controllerType === "Vive") { switchViewControllerMapping.from(Controller.Standard.LeftPrimaryThumb).to(function (value) { if (value === 1.0) { setMonitorShowsCameraViewAndSendToQml(!monitorShowsCameraView); } return; }); } } var takeSnapshotControllerMapping; var takeSnapshotControllerMappingName = 'Hifi-SpectatorCamera-Mapping-TakeSnapshot'; var flash = false; function setFlashStatus(enabled) { var cameraPosition = Entities.getEntityProperties(camera, ["positon"]).position; if (enabled) { if (camera) { Audio.playSound(SOUND_FLASH_ON, { position: cameraPosition, localOnly: true, volume: 0.8 }); flash = Entities.addEntity({ "collidesWith": "", "collisionMask": 0, "color": { "blue": 173, "green": 252, "red": 255 }, "cutoff": 90, "dimensions": { "x": 4, "y": 4, "z": 4 }, "dynamic": false, "falloffRadius": 0.20000000298023224, "intensity": 37, "isSpotlight": true, "localRotation": { w: 1, x: 0, y: 0, z: 0 }, "localPosition": { x: 0, y: -0.005, z: -0.08 }, "name": "Camera Flash", "type": "Light", "parentID": camera, }, true); } } else { if (flash) { Audio.playSound(SOUND_FLASH_OFF, { position: cameraPosition, localOnly: true, volume: 0.8 }); Entities.deleteEntity(flash); flash = false; } } } function onStillSnapshotTaken() { Render.getConfig("SecondaryCameraJob.ToneMapping").curve = 1; sendToQml({ method: 'finishedProcessingStillSnapshot' }); } function maybeTakeSnapshot() { if (camera) { sendToQml({ method: 'startedProcessingStillSnapshot' }); Render.getConfig("SecondaryCameraJob.ToneMapping").curve = 0; // Wait a moment before taking the snapshot for the tonemapping curve to update Script.setTimeout(function () { Audio.playSound(SOUND_SNAPSHOT, { position: { x: MyAvatar.position.x, y: MyAvatar.position.y, z: MyAvatar.position.z }, localOnly: true, volume: 1.0 }); Window.takeSecondaryCameraSnapshot(); }, 250); } else { sendToQml({ method: 'finishedProcessingStillSnapshot' }); } } function on360SnapshotTaken() { if (monitorShowsCameraView) { setDisplay(true); } sendToQml({ method: 'finishedProcessing360Snapshot' }); } function maybeTake360Snapshot() { if (camera) { Audio.playSound(SOUND_SNAPSHOT, { position: { x: MyAvatar.position.x, y: MyAvatar.position.y, z: MyAvatar.position.z }, localOnly: true, volume: 1.0 }); if (HMD.active && monitorShowsCameraView) { setDisplay(false); } Window.takeSecondaryCamera360Snapshot(Entities.getEntityProperties(camera, ["positon"]).position); } } function registerTakeSnapshotControllerMapping() { takeSnapshotControllerMapping = Controller.newMapping(takeSnapshotControllerMappingName); if (controllerType === "OculusTouch") { takeSnapshotControllerMapping.from(Controller.Standard.RS).to(function (value) { if (value === 1.0) { maybeTakeSnapshot(); } return; }); } else if (controllerType === "Vive") { takeSnapshotControllerMapping.from(Controller.Standard.RightPrimaryThumb).to(function (value) { if (value === 1.0) { maybeTakeSnapshot(); } return; }); } } var controllerType = "Other"; function registerButtonMappings() { var VRDevices = Controller.getDeviceNames().toString(); if (VRDevices) { if (VRDevices.indexOf("Vive") !== -1) { controllerType = "Vive"; } else if (VRDevices.indexOf("OculusTouch") !== -1) { controllerType = "OculusTouch"; } else { sendToQml({ method: 'updateControllerMappingCheckbox', switchViewSetting: switchViewFromController, takeSnapshotSetting: takeSnapshotFromController, controller: controllerType }); return; // Neither Vive nor Touch detected } } if (!switchViewControllerMapping) { registerSwitchViewControllerMapping(); } setSwitchViewControllerMappingStatus(switchViewFromController); if (!takeSnapshotControllerMapping) { registerTakeSnapshotControllerMapping(); } setTakeSnapshotControllerMappingStatus(switchViewFromController); sendToQml({ method: 'updateControllerMappingCheckbox', switchViewSetting: switchViewFromController, takeSnapshotSetting: takeSnapshotFromController, controller: controllerType }); } // Function Name: onTabletButtonClicked() // // Description: // -Fired when the Spectator Camera app button is pressed. // // Relevant Variables: // -SPECTATOR_CAMERA_QML_SOURCE: The path to the SpectatorCamera QML // -onSpectatorCameraScreen: true/false depending on whether we're looking at the spectator camera app. var SPECTATOR_CAMERA_QML_SOURCE = Script.resolvePath("SpectatorCamera.qml"); var onSpectatorCameraScreen = false; function onTabletButtonClicked() { if (!tablet) { print("Warning in onTabletButtonClicked(): 'tablet' undefined!"); return; } if (onSpectatorCameraScreen) { // for toolbar-mode: go back to home screen, this will close the window. tablet.gotoHomeScreen(); } else { tablet.loadQMLSource(SPECTATOR_CAMERA_QML_SOURCE); } } function updateSpectatorCameraQML() { sendToQml({ method: 'initializeUI', masterSwitchOn: !!camera, flashCheckboxChecked: !!flash, monitorShowsCamView: monitorShowsCameraView }); registerButtonMappings(); Menu.setIsOptionChecked("Disable Preview", false); Menu.setIsOptionChecked("Mono Preview", true); } // Function Name: onTabletScreenChanged() // // Description: // -Called when the TabletScriptingInterface::screenChanged() signal is emitted. The "type" argument can be either the string // value of "Home", "Web", "Menu", "QML", or "Closed". The "url" argument is only valid for Web and QML. function onTabletScreenChanged(type, url) { onSpectatorCameraScreen = (type === "QML" && url === SPECTATOR_CAMERA_QML_SOURCE); wireEventBridge(onSpectatorCameraScreen); // Change button to active when window is first openend OR if the camera is on, false otherwise. if (button) { button.editProperties({ isActive: onSpectatorCameraScreen || camera }); } // In the case of a remote QML app, it takes a bit of time // for the event bridge to actually connect, so we have to wait... Script.setTimeout(function () { if (onSpectatorCameraScreen) { updateSpectatorCameraQML(); } }, 700); } // Function Name: sendToQml() // // Description: // -Use this function to send a message to the QML (i.e. to change appearances). The "message" argument is what is sent to // SpectatorCamera QML in the format "{method, params}", like json-rpc. See also fromQml(). function sendToQml(message) { tablet.sendToQml(message); } // Function Name: fromQml() // // Description: // -Called when a message is received from SpectatorCamera.qml. The "message" argument is what is sent from the SpectatorCamera QML // in the format "{method, params}", like json-rpc. See also sendToQml(). function fromQml(message) { switch (message.method) { case 'spectatorCameraOn': spectatorCameraOn(); break; case 'spectatorCameraOff': spectatorCameraOff(); break; case 'setMonitorShowsCameraView': setMonitorShowsCameraView(message.params); break; case 'changeSwitchViewFromControllerPreference': setSwitchViewFromController(message.params); break; case 'changeTakeSnapshotFromControllerPreference': setTakeSnapshotFromController(message.params); break; case 'updateCameravFoV': spectatorCameraConfig.vFoV = message.vFoV; break; case 'setFlashStatus': setFlashStatus(message.enabled); break; case 'takeSecondaryCameraSnapshot': maybeTakeSnapshot(); break; case 'takeSecondaryCamera360Snapshot': maybeTake360Snapshot(); break; case 'openSettings': if ((HMD.active && Settings.getValue("hmdTabletBecomesToolbar", false)) || (!HMD.active && Settings.getValue("desktopTabletBecomesToolbar", true))) { Desktop.show("hifi/dialogs/GeneralPreferencesDialog.qml", "GeneralPreferencesDialog"); } else { tablet.pushOntoStack("hifi/tablet/TabletGeneralPreferences.qml"); } break; default: print('Unrecognized message from SpectatorCamera.qml:', JSON.stringify(message)); } } // Function Name: onHMDChanged() // // Description: // -Called from C++ when HMD mode is changed. The argument "isHMDMode" is true if HMD is on; false otherwise. function onHMDChanged(isHMDMode) { registerButtonMappings(); if (!isHMDMode) { setMonitorShowsCameraView(false); } else { setDisplay(monitorShowsCameraView); } } // Function Name: shutdown() // // Description: // -shutdown() will be called when the script ends (i.e. is stopped). function shutdown() { spectatorCameraOff(); Window.domainChanged.disconnect(onDomainChanged); Window.geometryChanged.disconnect(resizeViewFinderOverlay); Window.stillSnapshotTaken.disconnect(onStillSnapshotTaken); Window.snapshot360Taken.disconnect(on360SnapshotTaken); addOrRemoveButton(true); if (tablet) { tablet.screenChanged.disconnect(onTabletScreenChanged); if (onSpectatorCameraScreen) { tablet.gotoHomeScreen(); } } HMD.displayModeChanged.disconnect(onHMDChanged); Controller.keyPressEvent.disconnect(keyPressEvent); if (switchViewControllerMapping) { switchViewControllerMapping.disable(); } if (takeSnapshotControllerMapping) { takeSnapshotControllerMapping.disable(); } } // Function Name: onDomainChanged() // // Description: // -A small utility function used when the Window.domainChanged() signal is fired. function onDomainChanged() { spectatorCameraOff(true); } // These functions will be called when the script is loaded. var SOUND_CAMERA_ON = SoundCache.getSound(Script.resolvePath("cameraOn.wav")); var SOUND_SNAPSHOT = SoundCache.getSound(Script.resolvePath("snap.wav")); var SOUND_FLASH_ON = SoundCache.getSound(Script.resolvePath("flashOn.wav")); var SOUND_FLASH_OFF = SoundCache.getSound(Script.resolvePath("flashOff.wav")); startup(); Script.scriptEnding.connect(shutdown); }()); // END LOCAL_SCOPE