├── src ├── i18n │ ├── pl.json │ ├── ru.json │ ├── it.json │ ├── de.json │ ├── LanguageWatcher.tsx │ └── index.ts ├── views │ ├── player │ │ ├── index.ts │ │ ├── sidebar │ │ │ ├── index.ts │ │ │ ├── InspectorTab.tsx │ │ │ ├── SettingsTab.tsx │ │ │ ├── CueSheetAccordion.tsx │ │ │ ├── Accordion.tsx │ │ │ ├── PlayheadSection.tsx │ │ │ ├── PlayerSidebarTabs.tsx │ │ │ ├── SelectedDroneAccordions.tsx │ │ │ └── PlayerSidebar.tsx │ │ ├── utils.ts │ │ ├── constants.ts │ │ ├── CoordinateSystemAxes.tsx │ │ ├── PlayerView.tsx │ │ ├── CameraSelectorChip.tsx │ │ ├── PlaybackSlider.tsx │ │ ├── Overlays.tsx │ │ ├── VelocityArrows.tsx │ │ ├── Scenery.tsx │ │ ├── TopOverlay.tsx │ │ ├── SelectionMarkers.tsx │ │ └── OverlayVisibilityController.ts │ └── validation │ │ ├── index.ts │ │ ├── PanelToggleChip.tsx │ │ └── ValidationView.tsx ├── features │ ├── ui │ │ ├── constants.ts │ │ ├── modes.ts │ │ ├── selectors.ts │ │ ├── actions.ts │ │ └── slice.ts │ ├── playback │ │ └── types.ts │ ├── selection │ │ ├── selectors.ts │ │ └── slice.ts │ ├── sidebar │ │ ├── types.ts │ │ ├── selectors.ts │ │ └── slice.ts │ ├── settings │ │ ├── types.ts │ │ ├── selectors.ts │ │ ├── DroneSizeSlider.tsx │ │ ├── LanguageSelector.tsx │ │ ├── DroneModelSelector.tsx │ │ ├── ScenerySelector.tsx │ │ ├── PlaybackSpeedSelector.tsx │ │ ├── FrameRateSelector.tsx │ │ ├── actions.ts │ │ └── slice.ts │ ├── validation │ │ ├── types.ts │ │ ├── constants.ts │ │ ├── HorizontalVelocityChartPanel.tsx │ │ ├── HorizontalAccelerationChartPanel.tsx │ │ ├── AltitudeChartPanel.tsx │ │ ├── VerticalVelocityChartPanel.tsx │ │ ├── VerticalAccelerationChartPanel.tsx │ │ ├── actions.ts │ │ ├── items.ts │ │ ├── ToggleValidationModeButton.tsx │ │ ├── panels.ts │ │ ├── ProximityChartPanel.tsx │ │ └── slice.ts │ ├── hotkeys │ │ ├── types.ts │ │ ├── selectors.ts │ │ ├── slice.ts │ │ ├── ShowHotkeysDialogButton.tsx │ │ ├── utils.ts │ │ └── keymap.ts │ ├── audio │ │ ├── selectors.ts │ │ └── slice.ts │ ├── index.ts │ ├── show │ │ ├── utils.ts │ │ ├── async.ts │ │ ├── actions.ts │ │ ├── types.ts │ │ └── slice.ts │ ├── sharing │ │ ├── ShareButton.tsx │ │ └── actions.ts │ └── three-d │ │ ├── actions.ts │ │ └── selectors.ts ├── desktop │ ├── index.js │ ├── launcher │ │ ├── dialogs.mjs │ │ ├── utils.mjs │ │ ├── ipc.mjs │ │ ├── media-protocol.mjs │ │ ├── window-title.mjs │ │ ├── media-buffers.mjs │ │ ├── app-menu.mjs │ │ ├── api-v1.mjs │ │ ├── show-loader.mjs │ │ ├── file-opener.mjs │ │ └── index.mjs │ └── preload │ │ ├── ipc.js │ │ └── index.js ├── hooks │ ├── useDarkMode.ts │ ├── useRefresh.ts │ ├── store.ts │ └── usePeriodicRefresh.ts ├── components │ ├── SkybrushLogo.tsx │ ├── buttons │ │ ├── VolumeButton.tsx │ │ ├── OpenButton.tsx │ │ ├── ReloadButton.tsx │ │ ├── ZoomOutButton.tsx │ │ ├── TrackDronesButton.tsx │ │ └── ToggleSidebarButton.tsx │ ├── SplashScreen.tsx │ ├── PageLoadingIndicator.tsx │ ├── WindowDragMoveArea.tsx │ ├── WindowTitleManager.tsx │ ├── MainTopLevelView.tsx │ ├── CentralHelperPanel.tsx │ ├── DragDropHandler.tsx │ ├── LoadingScreen.tsx │ └── AudioController.tsx ├── sagas │ └── index.ts ├── aframe │ ├── primitives │ │ ├── arrow.js │ │ └── drone-flock.js │ ├── index.js │ └── components │ │ ├── modifier-keys.js │ │ ├── glow-material.js │ │ └── arrow.js ├── utils │ ├── types.ts │ ├── platform.ts │ └── formatters.ts ├── index.tsx ├── icons │ └── VirtualReality.tsx ├── theme.ts ├── window.ts ├── constants.ts ├── store.ts ├── custom.d.ts └── app.tsx ├── assets ├── img │ └── logo.png ├── icons │ ├── splash.png │ ├── win │ │ └── skybrush.ico │ ├── linux │ │ └── skybrush.png │ ├── mac │ │ └── skybrush.icns │ └── README.md ├── fonts │ ├── Roboto-msdf.png │ ├── DSEG7-Classic │ │ ├── DSEG7Classic-Regular.ttf │ │ ├── DSEG7Classic-Regular.woff │ │ └── DSEG7Classic-Regular.woff2 │ └── DSEG14-Classic │ │ ├── DSEG14Classic-Regular.ttf │ │ ├── DSEG14Classic-Regular.woff │ │ └── DSEG14Classic-Regular.woff2 └── css │ ├── aframe.less │ ├── dseg.css │ └── kbd.css ├── launcher.mjs ├── types ├── globals.d.ts ├── aframe-types.d.ts └── config │ └── index.d.ts ├── i18next-parser.config.json ├── .npmrc ├── .editorconfig ├── config ├── default.ts ├── webapp.ts ├── baseline.ts ├── index.ts └── demo.ts ├── index.html ├── tsconfig.json ├── patches ├── express+5.2.1.patch ├── react-chartjs-2+2.11.2.patch └── aframe-environment-component+1.5.0.patch ├── webpack ├── preload.config.js ├── launcher.config.js ├── helpers.js ├── dist.config.js ├── electron.config.js └── browser.config.js ├── scripts └── skyc-to-json.mjs ├── electron-builder.json ├── .gitignore └── README.md /src/i18n/pl.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/ru.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/views/player/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './PlayerView'; 2 | -------------------------------------------------------------------------------- /src/views/validation/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ValidationView'; 2 | -------------------------------------------------------------------------------- /src/views/player/sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './PlayerSidebar'; 2 | -------------------------------------------------------------------------------- /assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/img/logo.png -------------------------------------------------------------------------------- /launcher.mjs: -------------------------------------------------------------------------------- 1 | import main from './src/desktop/launcher/index.mjs'; 2 | 3 | await main(); 4 | -------------------------------------------------------------------------------- /src/i18n/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "language": "Lingua" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "language": "Sprache" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/icons/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/icons/splash.png -------------------------------------------------------------------------------- /assets/fonts/Roboto-msdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/Roboto-msdf.png -------------------------------------------------------------------------------- /assets/icons/win/skybrush.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/icons/win/skybrush.ico -------------------------------------------------------------------------------- /assets/icons/linux/skybrush.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/icons/linux/skybrush.png -------------------------------------------------------------------------------- /assets/icons/mac/skybrush.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/icons/mac/skybrush.icns -------------------------------------------------------------------------------- /types/globals.d.ts: -------------------------------------------------------------------------------- 1 | // From `GitRevisionPlugin` via `webpack` 2 | declare const COMMIT_HASH: string; 3 | declare const VERSION: string; 4 | -------------------------------------------------------------------------------- /src/features/ui/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Number of recently opened files to keep in the list. 3 | */ 4 | export const RECENT_FILE_COUNT = 5; 5 | -------------------------------------------------------------------------------- /assets/fonts/DSEG7-Classic/DSEG7Classic-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/DSEG7-Classic/DSEG7Classic-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/DSEG7-Classic/DSEG7Classic-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/DSEG7-Classic/DSEG7Classic-Regular.woff -------------------------------------------------------------------------------- /assets/fonts/DSEG14-Classic/DSEG14Classic-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/DSEG14-Classic/DSEG14Classic-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/DSEG14-Classic/DSEG14Classic-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/DSEG14-Classic/DSEG14Classic-Regular.woff -------------------------------------------------------------------------------- /assets/fonts/DSEG7-Classic/DSEG7Classic-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/DSEG7-Classic/DSEG7Classic-Regular.woff2 -------------------------------------------------------------------------------- /assets/fonts/DSEG14-Classic/DSEG14Classic-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skybrush-io/viewer/HEAD/assets/fonts/DSEG14-Classic/DSEG14Classic-Regular.woff2 -------------------------------------------------------------------------------- /i18next-parser.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": ["src/**/*.{ts,tsx}"], 3 | "locales": ["en", "hu"], 4 | "sort": true, 5 | "output": "src/i18n/$LOCALE.json" 6 | } 7 | -------------------------------------------------------------------------------- /src/features/playback/types.ts: -------------------------------------------------------------------------------- 1 | export const isSupportedFrameRate = (fps: number): boolean => { 2 | return Number.isFinite(fps) && fps >= 1 && fps <= 1000; 3 | }; 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | @collmot:registry=https://npm.collmot.com 3 | @skybrush:registry=https://npm.collmot.com 4 | legacy-peer-deps=true 5 | 6 | -------------------------------------------------------------------------------- /src/desktop/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Module that contains everything that is needed for Skybrush Viewer only 3 | * when it is being run as a desktop application. 4 | */ 5 | -------------------------------------------------------------------------------- /src/features/selection/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { RootState } from '~/store'; 2 | 3 | export const getSelectedDroneIndices = (state: RootState) => 4 | state.selection.droneIndices; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,jsx,css,less}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /src/features/sidebar/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum containing the different tabs available in the sidebar. 3 | */ 4 | export enum SidebarTab { 5 | INSPECTOR = 'inspector', 6 | SETTINGS = 'settings', 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/useDarkMode.ts: -------------------------------------------------------------------------------- 1 | import useMediaQuery from '@mui/material/useMediaQuery'; 2 | 3 | const useDarkMode = () => useMediaQuery('(prefers-color-scheme: dark)'); 4 | 5 | export default useDarkMode; 6 | -------------------------------------------------------------------------------- /src/features/sidebar/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { RootState } from '~/store'; 2 | import { SidebarTab } from './types'; 3 | 4 | export const getActiveSidebarTab = (state: RootState) => 5 | state.sidebar.activeTab ?? SidebarTab.INSPECTOR; 6 | -------------------------------------------------------------------------------- /src/features/settings/types.ts: -------------------------------------------------------------------------------- 1 | export type DroneModelType = 'sphere' | 'quad' | 'flapper'; 2 | 3 | export function isValidDroneModelType(value: string): value is DroneModelType { 4 | return value === 'sphere' || value === 'quad' || value === 'flapper'; 5 | } 6 | -------------------------------------------------------------------------------- /config/default.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Empty configuration override, which preserves all the defaults. 3 | */ 4 | 5 | import { type ConfigOverrides } from 'config-overrides'; 6 | 7 | const overrides: ConfigOverrides = {}; 8 | 9 | export default overrides; 10 | -------------------------------------------------------------------------------- /src/features/ui/modes.ts: -------------------------------------------------------------------------------- 1 | export enum UIMode { 2 | PLAYER = 'player', 3 | VALIDATION = 'validation', 4 | } 5 | 6 | /** 7 | * Array containing the allowed UI mode constants, also defining a natural 8 | * ordering among UI modes. 9 | */ 10 | export const MODES: UIMode[] = [UIMode.PLAYER, UIMode.VALIDATION]; 11 | -------------------------------------------------------------------------------- /src/features/validation/types.ts: -------------------------------------------------------------------------------- 1 | export type ValidationSettings = { 2 | maxAccelerationXY?: number; 3 | maxAccelerationZ?: number; 4 | maxAccelerationZUp?: number; 5 | maxAltitude: number; 6 | maxVelocityXY: number; 7 | maxVelocityZ: number; 8 | maxVelocityZUp?: number; 9 | minDistance: number; 10 | }; 11 | -------------------------------------------------------------------------------- /src/views/player/utils.ts: -------------------------------------------------------------------------------- 1 | import { SCENE_CAMERA_ID } from './constants'; 2 | 3 | /** 4 | * Finds the camera in the scene. 5 | */ 6 | export function findSceneCamera(): any { 7 | const el = document.querySelector(`#${SCENE_CAMERA_ID}`); 8 | const cameraEl: any = el?.tagName === 'A-CAMERA' ? el : null; 9 | return cameraEl?.object3D; 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useRefresh.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react'; 2 | 3 | /** 4 | * A custom hook that triggers a re-render of the component when called. 5 | * @returns A function that can be called to trigger a re-render. 6 | */ 7 | export default function useRefresh() { 8 | const [, forceUpdate] = useReducer((x) => x + 1, 0); 9 | return forceUpdate; 10 | } 11 | -------------------------------------------------------------------------------- /src/views/player/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ID of the camera in the scene. 3 | * 4 | * This ID is used to find the tag representing the camera in the DOM. 5 | */ 6 | export const SCENE_CAMERA_ID = 'three-d-camera'; 7 | 8 | /** 9 | * CSS class to use for objects that can be selected in the scene. 10 | */ 11 | export const SELECTABLE_OBJECT_CLASS = 'three-d-selectable'; 12 | -------------------------------------------------------------------------------- /src/hooks/store.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector, useStore } from 'react-redux'; 2 | import type { AppStore, RootState, store } from '~/store'; 3 | 4 | export type AppDispatch = typeof store.dispatch; 5 | export const useAppDispatch = useDispatch.withTypes(); 6 | export const useAppSelector = useSelector.withTypes(); 7 | export const useAppStore = useStore.withTypes(); 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /src/features/hotkeys/types.ts: -------------------------------------------------------------------------------- 1 | export type HotkeyHandler = (keyEvent?: KeyboardEvent) => void; 2 | 3 | export type KeyMap = Record< 4 | string, 5 | // TODO: Use `import { ExtendedKeyMapOptions } from 'react-hotkeys';`! 6 | { 7 | name?: string; 8 | group?: GroupType; 9 | scopes: ScopeType[]; 10 | } & ({ sequence?: string } | { sequences?: string[] }) 11 | >; 12 | -------------------------------------------------------------------------------- /src/components/SkybrushLogo.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | import logo from '~/../assets/img/logo.png'; 4 | 5 | type SkybrushLogoProps = React.ComponentPropsWithoutRef<'img'> & { 6 | readonly width?: number; 7 | }; 8 | 9 | const SkybrushLogo = ({ width = 160, ...rest }: SkybrushLogoProps) => ( 10 | Skybrush Viewer 11 | ); 12 | 13 | export default SkybrushLogo; 14 | -------------------------------------------------------------------------------- /src/features/ui/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { RootState } from '~/store'; 2 | 3 | import type { UIMode } from './modes'; 4 | 5 | /** 6 | * Selector that returns the current UI mode. 7 | */ 8 | export const getCurrentMode = (state: RootState): UIMode => state.ui.mode; 9 | 10 | /** 11 | * Selector that returns the list of recently opened files. 12 | */ 13 | export const getRecentFiles = (state: RootState): string[] => 14 | state.ui.recentFiles; 15 | -------------------------------------------------------------------------------- /src/sagas/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The root saga of the Skybrush Viewer application. 3 | */ 4 | 5 | import { all } from 'redux-saga/effects'; 6 | 7 | import cameraAnimatorSaga from '~/features/three-d/saga'; 8 | 9 | import loaderSaga from './loader'; 10 | 11 | /** 12 | * The root saga of the Skybrush application. 13 | */ 14 | function* rootSaga() { 15 | const sagas = [loaderSaga(), cameraAnimatorSaga()]; 16 | yield all(sagas); 17 | } 18 | 19 | export default rootSaga; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./src", 5 | "jsx": "react-jsx", 6 | "outDir": "./dist/", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "paths": { 10 | "~/*": ["./*"] 11 | }, 12 | "resolveJsonModule": true, 13 | "strict": true, 14 | "target": "esnext", 15 | "typeRoots": ["./types", "./node_modules/@types"], 16 | "allowSyntheticDefaultImports": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/buttons/VolumeButton.tsx: -------------------------------------------------------------------------------- 1 | import VolumeOff from '@mui/icons-material/VolumeOff'; 2 | import VolumeUp from '@mui/icons-material/VolumeUp'; 3 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 4 | 5 | const VolumeButton = ({ 6 | muted, 7 | ...rest 8 | }: IconButtonProps & { readonly muted: boolean }) => ( 9 | 10 | {muted ? : } 11 | 12 | ); 13 | 14 | export default VolumeButton; 15 | -------------------------------------------------------------------------------- /src/aframe/primitives/arrow.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A-Frame primitive that creates an arrow entity. 3 | */ 4 | 5 | import AFrame from '@skybrush/aframe-components'; 6 | 7 | AFrame.registerPrimitive('a-arrow', { 8 | // Attaches the 'arrow' component by default. 9 | defaultComponents: { 10 | arrow: {}, 11 | }, 12 | mappings: { 13 | direction: 'arrow.direction', 14 | length: 'arrow.length', 15 | 'head-length': 'arrow.headLength', 16 | 'head-width': 'arrow.headWidth', 17 | color: 'arrow.color', 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /assets/css/aframe.less: -------------------------------------------------------------------------------- 1 | /* Tweaks for A-Frame's dialogs and modals to fit our styling */ 2 | 3 | .a-dialog { 4 | background-color: unset; 5 | font-family: unset; 6 | font-size: unset; 7 | } 8 | 9 | .a-dialog-text { 10 | font-size: unset; 11 | } 12 | 13 | .a-dialog-button { 14 | font-size: unset; 15 | border-radius: 30px; 16 | text-transform: uppercase; 17 | font-weight: bold; 18 | } 19 | 20 | .a-dialog-ok-button { 21 | background-color: #F44336; /* Material-UI red[500] */ 22 | color: white; 23 | } 24 | -------------------------------------------------------------------------------- /config/webapp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Default application configuration at startup when running as a web app. 3 | */ 4 | 5 | import { type ConfigOverrides } from 'config-overrides'; 6 | 7 | const overrides: ConfigOverrides = { 8 | buttons: { 9 | playbackHint: true, 10 | reload: false, 11 | }, 12 | io: { 13 | localFiles: false, 14 | }, 15 | modes: { 16 | deepLinking: true, 17 | validation: false, 18 | }, 19 | startAutomatically: false, 20 | useWelcomeScreen: false, 21 | }; 22 | 23 | export default overrides; 24 | -------------------------------------------------------------------------------- /src/desktop/launcher/dialogs.mjs: -------------------------------------------------------------------------------- 1 | import { dialog } from 'electron'; 2 | 3 | export const selectLocalShowFileForOpening = async () => { 4 | const { filePaths } = await dialog.showOpenDialog({ 5 | title: 'Open show file', 6 | properties: ['openFile'], 7 | filters: [ 8 | { name: 'Skybrush shows', extensions: ['skyc'] }, 9 | { name: 'All files', extensions: ['*'] }, 10 | ], 11 | }); 12 | 13 | if (filePaths && filePaths.length > 0) { 14 | return filePaths[0]; 15 | } 16 | 17 | return undefined; 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/usePeriodicRefresh.ts: -------------------------------------------------------------------------------- 1 | import { useHarmonicIntervalFn } from 'react-use'; 2 | import useRefresh from './useRefresh'; 3 | 4 | /** 5 | * A hook that triggers a refresh at a specified interval. 6 | * If the interval is null or undefined, no periodic refresh will be set up. 7 | * 8 | * @param interval The interval in milliseconds for the periodic refresh. 9 | */ 10 | export default function usePeriodicRefresh(interval?: number | null) { 11 | const forceUpdate = useRefresh(); 12 | useHarmonicIntervalFn(forceUpdate, interval); 13 | } 14 | -------------------------------------------------------------------------------- /src/desktop/launcher/utils.mjs: -------------------------------------------------------------------------------- 1 | import { platform } from 'node:os'; 2 | import { BrowserWindow } from 'electron'; 3 | 4 | export const getFirstMainWindow = (options = {}) => { 5 | const { required = false } = options; 6 | 7 | const allWindows = BrowserWindow.getAllWindows(); 8 | if (allWindows.length === 0) { 9 | if (required) { 10 | throw new Error('All windows are closed'); 11 | } else { 12 | return undefined; 13 | } 14 | } 15 | 16 | return allWindows[0]; 17 | }; 18 | 19 | export const isRunningOnMac = platform() === 'darwin'; 20 | -------------------------------------------------------------------------------- /patches/express+5.2.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/express/lib/view.js b/node_modules/express/lib/view.js 2 | index d66b4a2..6d9a93e 100644 3 | --- a/node_modules/express/lib/view.js 4 | +++ b/node_modules/express/lib/view.js 5 | @@ -78,7 +78,7 @@ function View(name, options) { 6 | debug('require "%s"', mod) 7 | 8 | // default engine export 9 | - var fn = require(mod).__express 10 | + var fn = null // require(mod).__express 11 | 12 | if (typeof fn !== 'function') { 13 | throw new Error('Module "' + mod + '" does not provide a view engine.') 14 | -------------------------------------------------------------------------------- /webpack/preload.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const baseConfig = require('./base.config.js'); 3 | 4 | const plugins = []; 5 | 6 | module.exports = merge(baseConfig, { 7 | entry: './src/desktop/preload/index.js', 8 | output: { 9 | filename: 'preload.bundle.js', 10 | }, 11 | 12 | /* also prevent evaluation of __dirname and __filename at build time in 13 | * launcher and preloader */ 14 | node: { 15 | __dirname: false, 16 | __filename: false, 17 | }, 18 | 19 | plugins, 20 | 21 | target: 'electron-renderer', 22 | }); 23 | -------------------------------------------------------------------------------- /src/aframe/index.js: -------------------------------------------------------------------------------- 1 | import AFrame from '@skybrush/aframe-components'; 2 | 3 | import 'aframe-environment-component'; 4 | import 'aframe-look-at-component'; 5 | 6 | import '@skybrush/aframe-components/advanced-camera-controls'; 7 | import '@skybrush/aframe-components/deallocate'; 8 | import '@skybrush/aframe-components/meshline'; 9 | 10 | import './components/arrow'; 11 | import './components/drone-flock'; 12 | import './components/glow-material'; 13 | import './components/modifier-keys'; 14 | 15 | import './primitives/arrow'; 16 | import './primitives/drone-flock'; 17 | 18 | export default AFrame; 19 | -------------------------------------------------------------------------------- /src/components/SplashScreen.tsx: -------------------------------------------------------------------------------- 1 | import { CoverPagePresentation as CoverPage } from 'react-cover-page'; 2 | 3 | import logo from '~/../assets/icons/splash.png'; 4 | 5 | type SplashScreenProps = { 6 | readonly visible: boolean; 7 | }; 8 | 9 | const SplashScreen = ({ visible = true }: SplashScreenProps) => ( 10 | } 14 | title={ 15 | 16 | skybrush viewer 17 | 18 | } 19 | /> 20 | ); 21 | 22 | export default SplashScreen; 23 | -------------------------------------------------------------------------------- /src/components/buttons/OpenButton.tsx: -------------------------------------------------------------------------------- 1 | import Folder from '@mui/icons-material/Folder'; 2 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 3 | import { Tooltip } from '@skybrush/mui-components'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | const OpenButton = (props: IconButtonProps) => { 7 | const { t } = useTranslation(); 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default OpenButton; 18 | -------------------------------------------------------------------------------- /src/components/buttons/ReloadButton.tsx: -------------------------------------------------------------------------------- 1 | import Refresh from '@mui/icons-material/Refresh'; 2 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 3 | import { Tooltip } from '@skybrush/mui-components'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | const ReloadButton = (props: IconButtonProps) => { 7 | const { t } = useTranslation(); 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default ReloadButton; 18 | -------------------------------------------------------------------------------- /src/views/validation/PanelToggleChip.tsx: -------------------------------------------------------------------------------- 1 | import Chip, { type ChipProps } from '@mui/material/Chip'; 2 | 3 | const style = { 4 | borderStyle: 'solid', 5 | borderWidth: '1px', 6 | cursor: 'pointer', 7 | } as const; 8 | 9 | type PanelToggleChipProps = ChipProps & { 10 | readonly selected: boolean; 11 | }; 12 | 13 | const PanelToggleChip = ({ selected, ...rest }: PanelToggleChipProps) => ( 14 | 21 | ); 22 | 23 | export default PanelToggleChip; 24 | -------------------------------------------------------------------------------- /src/components/buttons/ZoomOutButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | import ZoomOut from '@mui/icons-material/ZoomOut'; 4 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 5 | import { Tooltip } from '@skybrush/mui-components'; 6 | 7 | const ZoomOutButton = (props: IconButtonProps) => { 8 | const { t } = useTranslation(); 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default ZoomOutButton; 19 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | // NOTE: The `Record` type alias cannot be used here, as it makes the TypeScript 2 | // compiler unable to figure out if these recursive types are safe or not. 3 | // See: 4 | // ∙ https://github.com/microsoft/TypeScript/issues/41164 5 | // ∙ https://github.com/typescript-eslint/typescript-eslint/issues/2687 6 | export type NestedRecord = { [key: string]: NestedRecordField }; 7 | export type NestedRecordField = T | NestedRecord; 8 | 9 | // NOTE: TypeScript makes it more convenient to work with `undefined`, 10 | // but in certain situations `null` is still useful / necessary. 11 | export type Nullable = T | null; 12 | -------------------------------------------------------------------------------- /src/components/PageLoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import CircularProgress from '@mui/material/CircularProgress'; 3 | 4 | const styles = { 5 | root: { 6 | position: 'relative', 7 | display: 'inline-block', 8 | flex: 1, 9 | }, 10 | 11 | progress: { 12 | position: 'absolute', 13 | top: '50%', 14 | left: '50%', 15 | transform: 'translate(-50%, -50%)', 16 | }, 17 | } as const; 18 | 19 | const PageLoadingIndicator = () => ( 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | 27 | export default PageLoadingIndicator; 28 | -------------------------------------------------------------------------------- /src/components/buttons/TrackDronesButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | import CenterFocusStrong from '@mui/icons-material/CenterFocusStrong'; 4 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 5 | import { Tooltip } from '@skybrush/mui-components'; 6 | 7 | const TrackDronesButton = (props: IconButtonProps) => { 8 | const { t } = useTranslation(); 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default TrackDronesButton; 19 | -------------------------------------------------------------------------------- /src/i18n/LanguageWatcher.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { getLanguage } from '~/features/settings/selectors'; 5 | import { useAppSelector } from '~/hooks/store'; 6 | 7 | /** 8 | * Component that updates the language of the application whenever the 9 | * respective setting changes in the state. 10 | */ 11 | const LanguageWatcher = () => { 12 | const { i18n } = useTranslation(); 13 | const language = useAppSelector(getLanguage); 14 | 15 | useEffect(() => { 16 | void i18n.changeLanguage(language); 17 | }, [i18n, language]); 18 | 19 | return null; 20 | }; 21 | 22 | export default LanguageWatcher; 23 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Module that contains everything that is needed for Skybrush Viewer only 3 | * when it is being run as a desktop application. 4 | */ 5 | 6 | import config from 'config'; 7 | 8 | import { initI18N } from './i18n'; 9 | import { SkybrushViewer } from './startup'; 10 | 11 | import 'tippy.js/dist/tippy.css'; 12 | import 'tippy.js/themes/light-border.css'; 13 | 14 | await initI18N(); 15 | 16 | if (config.startAutomatically) { 17 | // Start the app automatically but do not export it to the page 18 | SkybrushViewer.run(); 19 | } else { 20 | // Export the SkybrushViewer class to the global context 21 | (window as any).SkybrushViewer = SkybrushViewer; 22 | } 23 | -------------------------------------------------------------------------------- /config/baseline.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Baseline values for the configuration options of the application. 3 | */ 4 | 5 | import { type Config } from 'config'; 6 | 7 | const baseline: Config = { 8 | buttons: { 9 | playbackHint: false, 10 | reload: true, 11 | }, 12 | io: { 13 | localFiles: true, 14 | }, 15 | language: { 16 | default: 'en', 17 | enabled: ['en', 'hu', 'ja', 'zh-Hans'], 18 | fallback: 'en', 19 | }, 20 | modes: { 21 | deepLinking: false, 22 | player: true, 23 | validation: true, 24 | vr: false, 25 | }, 26 | preloadedShow: {}, 27 | startAutomatically: true, 28 | useWelcomeScreen: true, 29 | }; 30 | 31 | export default baseline; 32 | -------------------------------------------------------------------------------- /src/features/hotkeys/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from '@reduxjs/toolkit'; 2 | 3 | import { UIMode } from '~/features/ui/modes'; 4 | import { getCurrentMode } from '~/features/ui/selectors'; 5 | 6 | import { HotkeyScope } from './keymap'; 7 | import type { RootState } from '~/store'; 8 | 9 | export const getActiveHotkeyScope = createSelector(getCurrentMode, (mode) => { 10 | switch (mode) { 11 | case UIMode.PLAYER: 12 | return HotkeyScope.PLAYER; 13 | case UIMode.VALIDATION: 14 | return HotkeyScope.VALIDATION; 15 | default: 16 | return HotkeyScope.GLOBAL; 17 | } 18 | }); 19 | 20 | export const isHotkeyDialogVisible = (state: RootState) => 21 | state.hotkeys.dialogVisible; 22 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file File for merging the default config with overrides from external files. 3 | */ 4 | import mergeWith from 'lodash-es/mergeWith.js'; 5 | 6 | import { type Config } from 'config'; 7 | import overrides from 'config-overrides'; 8 | 9 | import baseline from './baseline'; 10 | 11 | // Completely replace arrays in the configuration instead of merging them. 12 | const customizer = ( 13 | defaultValue: unknown, 14 | overrideValue: T 15 | ): T | undefined => { 16 | if (Array.isArray(defaultValue) && Array.isArray(overrideValue)) { 17 | return overrideValue; 18 | } 19 | }; 20 | 21 | const merged: Config = mergeWith(baseline, overrides, customizer); 22 | 23 | export default merged; 24 | -------------------------------------------------------------------------------- /src/desktop/launcher/ipc.mjs: -------------------------------------------------------------------------------- 1 | import { ipcMain as ipc } from 'electron-better-ipc'; 2 | 3 | import { selectLocalShowFileForOpening } from './dialogs.mjs'; 4 | import { getShowAsObjectFromLocalFile } from './show-loader.mjs'; 5 | import { setTitle } from './window-title.mjs'; 6 | 7 | const setupIpc = () => { 8 | ipc.answerRenderer( 9 | 'getShowAsObjectFromLocalFile', 10 | getShowAsObjectFromLocalFile 11 | ); 12 | ipc.answerRenderer( 13 | 'selectLocalShowFileForOpening', 14 | selectLocalShowFileForOpening 15 | ); 16 | ipc.answerRenderer('setTitle', ({ appName, representedFile }, window) => { 17 | setTitle(window, { appName, representedFile }); 18 | }); 19 | }; 20 | 21 | export default setupIpc; 22 | -------------------------------------------------------------------------------- /assets/css/dseg.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'DSEG14-Classic'; 3 | src: url('../fonts/DSEG14-Classic/DSEG14Classic-Regular.woff2') format('woff2'), 4 | url('../fonts/DSEG14-Classic/DSEG14Classic-Regular.woff') format('woff'), 5 | url('../fonts/DSEG14-Classic/DSEG14Classic-Regular.ttf') format('truetype'); 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | @font-face { 11 | font-family: 'DSEG7-Classic'; 12 | src: url('../fonts/DSEG7-Classic/DSEG7Classic-Regular.woff2') format('woff2'), 13 | url('../fonts/DSEG7-Classic/DSEG7Classic-Regular.woff') format('woff'), 14 | url('../fonts/DSEG7-Classic/DSEG7Classic-Regular.ttf') format('truetype'); 15 | font-weight: normal; 16 | font-style: normal; 17 | } 18 | -------------------------------------------------------------------------------- /src/features/selection/slice.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Slice of the state object that stores the selected drone indices in 3 | * the main view. 4 | */ 5 | 6 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; 7 | 8 | type SelectionSliceState = { 9 | droneIndices: number[]; 10 | }; 11 | 12 | const initialState: SelectionSliceState = { 13 | droneIndices: [], 14 | }; 15 | 16 | const { actions, reducer } = createSlice({ 17 | name: 'selection', 18 | initialState, 19 | reducers: { 20 | setSelectedDroneIndices(state, action: PayloadAction) { 21 | state.droneIndices = action.payload; 22 | }, 23 | }, 24 | }); 25 | 26 | export const { setSelectedDroneIndices } = actions; 27 | 28 | export default reducer; 29 | -------------------------------------------------------------------------------- /src/features/hotkeys/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { noPayload } from '@skybrush/redux-toolkit'; 3 | 4 | type HotkeysSliceState = { 5 | dialogVisible: boolean; 6 | }; 7 | 8 | const initialState: HotkeysSliceState = { 9 | dialogVisible: false, 10 | }; 11 | 12 | const { actions, reducer } = createSlice({ 13 | name: 'hotkeys', 14 | initialState, 15 | reducers: { 16 | showHotkeyDialog: noPayload((state) => { 17 | state.dialogVisible = true; 18 | }), 19 | 20 | closeHotkeyDialog: noPayload((state) => { 21 | state.dialogVisible = false; 22 | }), 23 | }, 24 | }); 25 | 26 | export const { closeHotkeyDialog, showHotkeyDialog } = actions; 27 | 28 | export default reducer; 29 | -------------------------------------------------------------------------------- /scripts/skyc-to-json.mjs: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import process from 'node:process'; 3 | 4 | import { loadCompiledShow } from '@skybrush/show-format'; 5 | import { program } from 'commander'; 6 | 7 | program 8 | .storeOptionsAsProperties(false) 9 | .requiredOption('-i, --input ', 'name of the input file') 10 | .requiredOption('-o, --output ', 'name of the output file') 11 | .parse(process.argv); 12 | 13 | /** 14 | * @param {{input: string, output: string}} options 15 | */ 16 | async function main(options) { 17 | const data = await fs.readFile(options.input); 18 | const show = await loadCompiledShow(data); 19 | const output = JSON.stringify(show, null, 2); 20 | await fs.writeFile(options.output, output); 21 | } 22 | 23 | await main(program.opts()); 24 | -------------------------------------------------------------------------------- /assets/icons/README.md: -------------------------------------------------------------------------------- 1 | Current icon was generated with the _Launcher icon generator_ from the 2 | _Android Asset Studio_: 3 | 4 | https://romannurik.github.io/AndroidAssetStudio/icons-launcher.html 5 | 6 | Background color: #dc3545 ("Skybrush red", probably from the Bootstrap 7 | palette) 8 | Clipart: search 9 | Font (if we need): Allura 10 | Padding (if we need text): 0% 11 | 12 | Take the 512px version, scale it up to 1024px and add rounded corners in 13 | GIMP with a corner radius of 180px. Then upload the image to the following 14 | URL to get it converted to .icns format: 15 | 16 | https://cloudconvert.com/ 17 | 18 | Also add rounded corners to the 512px version with a corner radius of 90px, 19 | and upload this icon to the following URL to get it converted to .ico format 20 | for Windows: 21 | 22 | https://icoconvert.com/ 23 | -------------------------------------------------------------------------------- /src/aframe/primitives/drone-flock.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A-Frame primitive that creates a drone flock entity that will contain the 3 | * individual drones. 4 | */ 5 | 6 | import AFrame from '@skybrush/aframe-components'; 7 | 8 | AFrame.registerPrimitive('a-drone-flock', { 9 | // Attaches the 'drone-flock' component by default. 10 | defaultComponents: { 11 | 'drone-flock': {}, 12 | }, 13 | mappings: { 14 | 'drone-model': 'drone-flock.droneModel', 15 | 'drone-radius': 'drone-flock.droneRadius', 16 | indoor: 'drone-flock.indoor', 17 | 'label-color': 'drone-flock.labelColor', 18 | 'scale-labels': 'drone-flock.scaleLabels', 19 | 'show-glow': 'drone-flock.showGlow', 20 | 'show-labels': 'drone-flock.showLabels', 21 | 'show-yaw': 'drone-flock.showYaw', 22 | size: 'drone-flock.size', 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/icons/VirtualReality.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { type SvgIconProps } from '@mui/material/SvgIcon'; 2 | 3 | const VirtualReality = (props: SvgIconProps) => ( 4 | 5 | {/* https://materialdesignicons.com/icon/google-cardboard */} 6 | 7 | 8 | ); 9 | 10 | export default VirtualReality; 11 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.collmot.skybrush.viewer", 3 | "productName": "Skybrush Viewer", 4 | 5 | "artifactName": "${productName} ${version}.${ext}", 6 | 7 | "files": ["!**/*", "package.json", { "from": "build" }], 8 | 9 | "fileAssociations": [ 10 | { 11 | "ext": "skyc", 12 | "description": "Skybrush compiled show file", 13 | "role": "Viewer" 14 | } 15 | ], 16 | 17 | "linux": { 18 | "category": "Utility", 19 | "target": { 20 | "target": "deb", 21 | "arch": "x64" 22 | } 23 | }, 24 | 25 | "mac": { 26 | "category": "public.app-category.utilities", 27 | "target": { 28 | "target": "dmg", 29 | "arch": "universal" 30 | } 31 | }, 32 | 33 | "win": { 34 | "target": { 35 | "target": "portable", 36 | "arch": "x64" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/features/audio/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { RootState } from '~/store'; 2 | 3 | /** 4 | * Selector that returns the current audio URL. 5 | */ 6 | export const getCurrentAudioUrl = (state: RootState): string | undefined => 7 | state.audio.url; 8 | 9 | /** 10 | * Selector that returns whether there is audio associated to the currently 11 | * loaded show. 12 | */ 13 | export const hasAudio = (state: RootState) => state.audio.url !== undefined; 14 | 15 | /** 16 | * Selector that returns whether the audio subsystem is ready to play the 17 | * audio. 18 | */ 19 | export const isAudioReadyToPlay = (state: RootState) => 20 | !hasAudio(state) || (!state.audio.loading && !state.audio.seeking); 21 | 22 | /** 23 | * Selector that returns whether the audio playback is muted. 24 | */ 25 | export const isAudioMuted = (state: RootState): boolean => state.audio.muted; 26 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import audioReducer from './audio/slice'; 4 | import hotkeysReducer from './hotkeys/slice'; 5 | import playbackReducer from './playback/slice'; 6 | import selectionReducer from './selection/slice'; 7 | import settingsReducer from './settings/slice'; 8 | import showReducer from './show/slice'; 9 | import sidebarReducer from './sidebar/slice'; 10 | import threeDReducer from './three-d/slice'; 11 | import uiReducer from './ui/slice'; 12 | import validationReducer from './validation/slice'; 13 | 14 | export default combineReducers({ 15 | audio: audioReducer, 16 | hotkeys: hotkeysReducer, 17 | playback: playbackReducer, 18 | selection: selectionReducer, 19 | settings: settingsReducer, 20 | show: showReducer, 21 | sidebar: sidebarReducer, 22 | threeD: threeDReducer, 23 | ui: uiReducer, 24 | validation: validationReducer, 25 | }); 26 | -------------------------------------------------------------------------------- /patches/react-chartjs-2+2.11.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-chartjs-2/es/index.js b/node_modules/react-chartjs-2/es/index.js 2 | index 7ca177d..a3c9be6 100644 3 | --- a/node_modules/react-chartjs-2/es/index.js 4 | +++ b/node_modules/react-chartjs-2/es/index.js 5 | @@ -249,7 +249,9 @@ var ChartComponent = /*#__PURE__*/function (_React$Component) { 6 | if (current && current.type === next.type && next.data) { 7 | // Be robust to no data. Relevant for other update mechanisms as in chartjs-plugin-streaming. 8 | // The data array must be edited in place. As chart.js adds listeners to it. 9 | - current.data.splice(next.data.length); 10 | + if (current.data.length > next.data.length) { 11 | + current.data.splice(next.data.length); 12 | + } 13 | next.data.forEach(function (point, pid) { 14 | current.data[pid] = next.data[pid]; 15 | }); 16 | -------------------------------------------------------------------------------- /config/demo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Default application configuration at startup to run a local hardcoded demo. 3 | */ 4 | 5 | import type { ShowSpecification } from '@skybrush/show-format'; 6 | import { type ConfigOverrides } from 'config-overrides'; 7 | 8 | import audio from '~/../assets/shows/demo.mp3'; 9 | 10 | const show = async (): Promise => 11 | import( 12 | /* webpackChunkName: "show" */ '~/../assets/shows/demo.json' 13 | ) as unknown as ShowSpecification; 14 | 15 | const overrides: ConfigOverrides = { 16 | buttons: { 17 | playbackHint: true, 18 | }, 19 | electronBuilder: { 20 | productName: 'Skybrush Viewer Demo', 21 | }, 22 | io: { 23 | localFiles: false, 24 | }, 25 | modes: { 26 | deepLinking: true, 27 | validation: false, 28 | }, 29 | preloadedShow: { 30 | audio, 31 | show, 32 | }, 33 | useWelcomeScreen: false, 34 | }; 35 | 36 | export default overrides; 37 | -------------------------------------------------------------------------------- /types/aframe-types.d.ts: -------------------------------------------------------------------------------- 1 | // This file is needed to allow custom A-Frame elements like 2 | // to be used in TSX files without type errors. 3 | 4 | import type * as React from 'react'; 5 | 6 | type AnyKey = Record; 7 | 8 | type CustomAFrameElement = React.DetailedHTMLProps< 9 | React.HTMLAttributes, 10 | HTMLElement 11 | > & 12 | T; 13 | 14 | declare module 'react' { 15 | namespace JSX { 16 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 17 | interface IntrinsicElements { 18 | 'a-arrow': CustomAFrameElement; 19 | 'a-assets': CustomAFrameElement; 20 | 'a-asset-item': CustomAFrameElement; 21 | 'a-box': CustomAFrameElement; 22 | 'a-camera': CustomAFrameElement; 23 | 'a-drone-flock': CustomAFrameElement; 24 | 'a-entity': CustomAFrameElement; 25 | 'a-scene': CustomAFrameElement; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/aframe/components/modifier-keys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A-Frame component to keep track of the state of modifier keys. 3 | */ 4 | import AFrame from '@skybrush/aframe-components'; 5 | 6 | AFrame.registerSystem('modifier-keys', { 7 | init() { 8 | this.altKey = false; 9 | this.ctrlKey = false; 10 | this.metaKey = false; 11 | this.shiftKey = false; 12 | 13 | const handleKeyEvent = this._handleKeyEvent.bind(this); 14 | 15 | window.addEventListener('keydown', handleKeyEvent); 16 | window.addEventListener('keyup', handleKeyEvent); 17 | }, 18 | 19 | _handleKeyEvent(event) { 20 | this.altKey = event.altKey; 21 | this.ctrlKey = event.ctrlKey; 22 | this.metaKey = event.metaKey; 23 | this.shiftKey = event.shiftKey; 24 | }, 25 | 26 | updateSyntheticEvent(event) { 27 | event.altKey = this.altKey; 28 | event.ctrlKey = this.ctrlKey; 29 | event.metaKey = this.metaKey; 30 | event.shiftKey = this.shiftKey; 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/features/show/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | skybrushRotationToQuaternion, 3 | type Pose, 4 | } from '@skybrush/aframe-components/lib/spatial'; 5 | import type { Camera } from '@skybrush/show-format'; 6 | import type { ShowDataSource } from './types'; 7 | 8 | export const DEFAULT_CAMERA_ORIENTATION = skybrushRotationToQuaternion([ 9 | 90, 0, -90, 10 | ]); 11 | 12 | /** 13 | * Returns the pose of a Skybrush camera, replacing missing components with 14 | * reasonable defaults. 15 | * 16 | * The result is returned in Skybrush conventions. 17 | */ 18 | export function getCameraPose(camera: Camera): Pose { 19 | return { 20 | position: camera.position ?? [0, 0, 0], 21 | orientation: camera.orientation ?? DEFAULT_CAMERA_ORIENTATION, 22 | }; 23 | } 24 | 25 | /** 26 | * Returns whether a show data source is reloadable. 27 | */ 28 | export function isShowDataSourceReloadable( 29 | source: ShowDataSource | null | undefined 30 | ): boolean { 31 | return source?.type === 'file'; 32 | } 33 | -------------------------------------------------------------------------------- /src/views/validation/ValidationView.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | 3 | import WindowDragMoveArea from '~/components/WindowDragMoveArea'; 4 | 5 | import ChartGrid from './ChartGrid'; 6 | import ValidationHeader from './ValidationHeader'; 7 | import ValidationSidebar from './ValidationSidebar'; 8 | 9 | const SIDEBAR_WIDTH = 160; 10 | 11 | const styles = { 12 | root: { 13 | backgroundColor: '#303030', 14 | display: 'flex', 15 | flex: 1, 16 | flexDirection: 'column', 17 | overflow: 'hidden', 18 | pr: 1, 19 | }, 20 | } as const; 21 | 22 | const ValidationView = () => ( 23 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | 37 | export default ValidationView; 38 | -------------------------------------------------------------------------------- /src/features/sharing/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Share from '@mui/icons-material/Share'; 5 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 6 | import { Tooltip } from '@skybrush/mui-components'; 7 | 8 | import { hasLoadedShowFile } from '~/features/show/selectors'; 9 | import type { RootState } from '~/store'; 10 | import { getSharingLink } from './actions'; 11 | 12 | const ShareButton = (props: IconButtonProps) => { 13 | const { t } = useTranslation(); 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default connect( 24 | // mapStateToProps 25 | (state: RootState) => ({ 26 | disabled: !hasLoadedShowFile(state), 27 | }), 28 | // mapDispatchToProps 29 | { 30 | onClick: getSharingLink, 31 | } 32 | )(ShareButton); 33 | -------------------------------------------------------------------------------- /src/desktop/preload/ipc.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer: ipc } = require('electron-better-ipc'); 2 | 3 | const actionsFromRenderer = {}; 4 | 5 | const noop = () => { 6 | /* intentionally left empty */ 7 | }; 8 | 9 | const getActionByName = (name) => { 10 | const func = actionsFromRenderer[name]; 11 | if (func) { 12 | return func; 13 | } 14 | 15 | console.warn(`${name}() action was not provided by the renderer`); 16 | return noop; 17 | }; 18 | 19 | const createActionProxy = 20 | (name) => 21 | (...args) => 22 | getActionByName(name)(...args); 23 | 24 | module.exports = { 25 | receiveActionsFromRenderer(actions) { 26 | Object.assign(actionsFromRenderer, actions); 27 | }, 28 | 29 | setupIpc() { 30 | ipc.answerMain( 31 | 'notifyFileOpeningRequest', 32 | createActionProxy('loadShowFromLocalFile') 33 | ); 34 | 35 | ipc.answerMain( 36 | 'loadShowFromObject', 37 | createActionProxy('loadShowFromObject') 38 | ); 39 | 40 | ipc.answerMain('setUIMode', createActionProxy('setUIMode')); 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/views/player/sidebar/InspectorTab.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { Accordion, AccordionDetails, AccordionSummary } from './Accordion'; 5 | import CueSheetAccordion from './CueSheetAccordion'; 6 | import MetadataSection from './MetadataSection'; 7 | import PlayheadSection from './PlayheadSection'; 8 | import SelectedDroneAccordions from './SelectedDroneAccordions'; 9 | 10 | /** 11 | * The inspector tab of the player sidebar. 12 | */ 13 | const InspectorTab = () => { 14 | const { t } = useTranslation(); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | {t('inspector.metadata.summary')} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default InspectorTab; 34 | -------------------------------------------------------------------------------- /src/views/player/CoordinateSystemAxes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const AxisColors = { 4 | x: '#f44', 5 | y: '#4f4', 6 | z: '#06f', 7 | } as const; 8 | 9 | type CoordinateSystemAxesProps = { 10 | readonly leftHanded?: boolean; 11 | readonly length?: number; 12 | readonly lineWidth?: number; 13 | }; 14 | 15 | /** 16 | * Component that renders unit-length coordinate system axes at the origin. 17 | */ 18 | const CoordinateSystemAxes = ({ 19 | leftHanded = false, 20 | length = 1, 21 | lineWidth = 3, 22 | }: CoordinateSystemAxesProps) => ( 23 | <> 24 | 27 | 32 | 35 | 36 | ); 37 | 38 | export default React.memo(CoordinateSystemAxes); 39 | -------------------------------------------------------------------------------- /src/utils/platform.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions related to platform detection and other 3 | * platform-specific stuff. 4 | */ 5 | 6 | /** 7 | * Constant that evaluates to true if we are running on a Mac, false 8 | * otherwise. 9 | */ 10 | export const isRunningOnMac: boolean = navigator.platform.includes('Mac'); 11 | 12 | /** 13 | * Constant that evaluates to true if we are running on Windows, false 14 | * otherwise. 15 | */ 16 | export const isRunningOnWindows = 17 | navigator.platform.includes('Win32') || 18 | navigator.platform.includes('Windows'); 19 | 20 | /** 21 | * Constant that evaluates to the name of the platform-specific hotkey 22 | * modifier: Ctrl on Windows and Cmd on Mac. 23 | */ 24 | export const platformModifierKey = isRunningOnMac ? 'Cmd' : 'Ctrl'; 25 | 26 | /** 27 | * Constant that evaluates to the platform-specific path separator symbol: 28 | * - Forward slash (/) on Linux and Mac 29 | * - Backslash (\) on Windows 30 | */ 31 | export const platformPathSeparator = isRunningOnWindows ? '\\' : '/'; 32 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Theme setup for Material-UI. 3 | */ 4 | 5 | import { connect } from 'react-redux'; 6 | 7 | import { blue, lightBlue, red } from '@mui/material/colors'; 8 | 9 | import { createThemeProvider, ThemeType } from '@skybrush/app-theme-mui'; 10 | import type { ToastOptions } from 'react-hot-toast'; 11 | 12 | /** 13 | * Specialized Material-UI theme provider that is aware about the user's 14 | * preference about whether to use a dark or a light theme. 15 | */ 16 | const DarkModeAwareThemeProvider = createThemeProvider({ 17 | primaryColor: (isDark: boolean) => (isDark ? red : blue), 18 | secondaryColor: (isDark: boolean) => (isDark ? lightBlue : red), 19 | }); 20 | 21 | /** 22 | * Styling options for toast notifications. 23 | */ 24 | export const toastOptions: ToastOptions = { 25 | position: 'top-center', 26 | style: { 27 | background: '#333', 28 | color: '#fff', 29 | }, 30 | }; 31 | 32 | /** 33 | * Connects the theme provider to the Redux store. 34 | */ 35 | export default connect( 36 | // mapStateToProps 37 | () => ({ 38 | type: ThemeType.DARK, 39 | }) 40 | )(DarkModeAwareThemeProvider); 41 | -------------------------------------------------------------------------------- /src/views/player/PlayerView.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Component that shows a three-dimensional view of the drone flock. 3 | */ 4 | 5 | import type React from 'react'; 6 | 7 | import Box from '@mui/material/Box'; 8 | 9 | import AudioController from '~/components/AudioController'; 10 | import LoadingScreen from '~/components/LoadingScreen'; 11 | import WelcomeScreen from '~/components/WelcomeScreen'; 12 | import { cameraRef } from '~/features/three-d/saga'; 13 | 14 | import Overlays from './Overlays'; 15 | import OverlayVisibilityController from './OverlayVisibilityController'; 16 | import PlayerSidebar from './sidebar'; 17 | import ThreeDView from './ThreeDView'; 18 | 19 | const PlayerView = ({ 20 | screenRef, 21 | }: { 22 | readonly screenRef: React.RefObject; 23 | }) => ( 24 | <> 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | 38 | export default PlayerView; 39 | -------------------------------------------------------------------------------- /src/components/WindowDragMoveArea.tsx: -------------------------------------------------------------------------------- 1 | import Box, { type BoxProps } from '@mui/material/Box'; 2 | import { systemFont } from '@skybrush/app-theme-mui'; 3 | 4 | import { isRunningOnMac } from '~/utils/platform'; 5 | import { isElectronWindow } from '~/window'; 6 | 7 | const isUsingNativeTitleBar = true; 8 | const isShowingDragMoveArea = 9 | !isUsingNativeTitleBar && isElectronWindow(window) && isRunningOnMac; 10 | 11 | export const WINDOW_DRAG_MOVE_AREA_HEIGHT = isShowingDragMoveArea ? 36 : 0; 12 | 13 | const style = { 14 | display: 'flex', 15 | justifyContent: 'center', 16 | alignItems: 'center', 17 | fontFamily: systemFont, 18 | WebkitAppRegion: 'drag', 19 | WebkitUserSelect: 'none', 20 | left: 0, 21 | top: 0, 22 | right: 0, 23 | height: WINDOW_DRAG_MOVE_AREA_HEIGHT, 24 | position: 'absolute', 25 | textAlign: 'center', 26 | } as const; 27 | 28 | /** 29 | * Overlay at the top of the window that acts as a draggable area on macOS 30 | * to allow the window to be moved around. 31 | */ 32 | const WindowDragMoveArea = (props: BoxProps) => 33 | isShowingDragMoveArea ? : null; 34 | 35 | export default WindowDragMoveArea; 36 | -------------------------------------------------------------------------------- /webpack/launcher.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const { merge } = require('webpack-merge'); 3 | const baseConfig = require('./base.config.js'); 4 | 5 | const plugins = [ 6 | new webpack.DefinePlugin({ 7 | // work around a misbehaving debug() module that tries to assign to 8 | // process.env.DEBUG. Also needs a global variable named 9 | // __runtime_process_env 10 | 'process.env.DEBUG': '__runtime_process_env.DEBUG', 11 | 12 | // need to handle import.meta.url before Webpack does. Webpack would leak 13 | // the name of the compilation folder and we don't want that, but we can't 14 | // leave import.meta.url in the file as-is because Electron would choke 15 | // on it. 16 | 'import.meta.url': '"file:///"', 17 | }), 18 | ]; 19 | 20 | module.exports = merge(baseConfig, { 21 | entry: './launcher.mjs', 22 | output: { 23 | filename: 'launcher.bundle.js', 24 | }, 25 | 26 | /* also prevent evaluation of __dirname and __filename at build time in 27 | * launcher and preloader */ 28 | node: { 29 | __dirname: false, 30 | __filename: false, 31 | }, 32 | 33 | plugins, 34 | target: 'electron-main', 35 | }); 36 | -------------------------------------------------------------------------------- /src/window.ts: -------------------------------------------------------------------------------- 1 | import type { ActionCreator } from 'redux'; 2 | 3 | import type { ShowSpecification } from '@skybrush/show-format'; 4 | 5 | /** 6 | * Type specification for the bridge that we inject into the window object 7 | * when running in Electron. 8 | */ 9 | export type ElectronBridge = { 10 | isElectron: boolean; 11 | 12 | getShowAsObjectFromLocalFile: ( 13 | filename: string 14 | ) => Promise; 15 | provideActions: ( 16 | actionCreators: Record> 17 | ) => void; 18 | selectLocalShowFileForOpening: () => Promise; 19 | setTitle: (options: { 20 | appName?: string; 21 | representedFile?: string; 22 | alternateFile?: string; 23 | }) => void; 24 | }; 25 | 26 | export type WindowWithBridge = Window & { 27 | bridge: ElectronBridge; 28 | }; 29 | 30 | export function isElectronWindow(window: Window): window is WindowWithBridge { 31 | const win: any = window; 32 | return ( 33 | win.bridge !== undefined && (win as WindowWithBridge).bridge.isElectron 34 | ); 35 | } 36 | 37 | export function getElectronBridge(): ElectronBridge | undefined { 38 | return isElectronWindow(window) ? window.bridge : undefined; 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VSCode settings 2 | .vscode 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Generated API documentation 33 | doc/api/ 34 | 35 | # Stuff packed by webpack 36 | build/ 37 | dist/ 38 | webpack-stats.json 39 | 40 | # Typescript type definitions 41 | typings/ 42 | 43 | # Dependency directories 44 | node_modules 45 | jspm_packages 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Environment file containing sensitive data (e.g.: API keys) 54 | .env 55 | 56 | # Show files used as examples during testing 57 | assets/shows/ 58 | 59 | # Large icons 60 | assets/icons/*512.png 61 | assets/icons/*1024.png 62 | 63 | -------------------------------------------------------------------------------- /src/components/buttons/ToggleSidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import MenuOpen from '@mui/icons-material/MenuOpen'; 5 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 6 | import { Tooltip } from '@skybrush/mui-components'; 7 | 8 | import { isSidebarOpen, toggleSidebar } from '~/features/sidebar/slice'; 9 | import { useAppSelector } from '~/hooks/store'; 10 | 11 | /** 12 | * Toggle button for the sidebar. 13 | */ 14 | const ToggleSidebarButton = (props: IconButtonProps) => { 15 | const dispatch = useDispatch(); 16 | const open = useAppSelector(isSidebarOpen); 17 | const handleClick = () => dispatch(toggleSidebar()); 18 | const { t } = useTranslation(); 19 | return ( 20 | 21 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default ToggleSidebarButton; 37 | -------------------------------------------------------------------------------- /src/features/settings/selectors.ts: -------------------------------------------------------------------------------- 1 | import config from 'config'; 2 | 3 | import { DEFAULT_DRONE_MODEL, DEFAULT_PLAYBACK_FPS } from '~/constants'; 4 | import type { RootState } from '~/store'; 5 | import { isSupportedFrameRate } from '../playback/types'; 6 | 7 | export const shouldShowPlaybackHintButton = () => config.buttons.playbackHint; 8 | export const shouldUseWelcomeScreen = () => config.useWelcomeScreen; 9 | 10 | export const getDroneModel = (state: RootState) => 11 | state.settings.threeD.droneModel ?? DEFAULT_DRONE_MODEL; 12 | 13 | export const getRawDroneRadiusSetting = (state: RootState) => { 14 | return state.settings.threeD.droneRadius ?? 1; 15 | }; 16 | 17 | export const getLanguage = (state: RootState) => 18 | state.settings.general?.language ?? config.language.default; 19 | 20 | export const getScenery = (state: RootState) => state.settings.threeD.scenery; 21 | 22 | export const getSimulatedPlaybackFrameRate = (state: RootState) => { 23 | const fps = state.settings.playback?.fps ?? DEFAULT_PLAYBACK_FPS; 24 | return isSupportedFrameRate(fps) ? fps : DEFAULT_PLAYBACK_FPS; 25 | }; 26 | 27 | export const getPlaybackSliderStepSize = (state: RootState) => 28 | 1 / getSimulatedPlaybackFrameRate(state); 29 | -------------------------------------------------------------------------------- /src/desktop/launcher/media-protocol.mjs: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | import { protocol } from 'electron'; 3 | 4 | import { getAudioBuffer } from './media-buffers.mjs'; 5 | 6 | /** 7 | * Registers an Electron protocol handler for the media:// URI scheme that is 8 | * used to transfer audio data between the main and the renderer process. 9 | */ 10 | const registerMediaProtocol = () => { 11 | protocol.registerFileProtocol('media', (request, callback) => { 12 | try { 13 | const parsedUrl = new URL(request.url); 14 | const index = 15 | parsedUrl.host === 'audio' 16 | ? Number.parseInt(parsedUrl.pathname.slice(1), 10) 17 | : -1; 18 | 19 | const audioBuffer = index >= 0 ? getAudioBuffer(index) : null; 20 | 21 | /* Error -6 = file not found in net_error_list.h in Chromium */ 22 | callback( 23 | audioBuffer && audioBuffer.path 24 | ? { path: audioBuffer.path } 25 | : { error: -6 } 26 | ); 27 | } catch (error) { 28 | console.error('Unexpected error in media:// protocol handler'); 29 | console.error(error); 30 | callback({ error: -6 }); 31 | } 32 | }); 33 | }; 34 | 35 | export default registerMediaProtocol; 36 | -------------------------------------------------------------------------------- /src/views/player/sidebar/SettingsTab.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import List from '@mui/material/List'; 3 | 4 | import DroneSizeSlider from '~/features/settings/DroneSizeSlider'; 5 | import FrameRateSelector from '~/features/settings/FrameRateSelector'; 6 | import LanguageSelector from '~/features/settings/LanguageSelector'; 7 | import PlaybackSpeedSelector from '~/features/settings/PlaybackSpeedSelector'; 8 | import ScenerySelector from '~/features/settings/ScenerySelector'; 9 | import ThreeDViewSettingToggles from '~/features/settings/ThreeDViewSettingToggles'; 10 | 11 | /** 12 | * The settings tab of the player sidebar. 13 | */ 14 | const SettingsTab = () => ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | export default SettingsTab; 41 | -------------------------------------------------------------------------------- /src/features/validation/constants.ts: -------------------------------------------------------------------------------- 1 | import { 2 | blue, 3 | green, 4 | lime, 5 | orange, 6 | pink, 7 | purple, 8 | red, 9 | teal, 10 | } from '@mui/material/colors'; 11 | 12 | import { type ValidationSettings } from './types'; 13 | 14 | /** 15 | * Number of samples to take from trajectories per second for the validation. 16 | */ 17 | export const SAMPLES_PER_SECOND = 5; 18 | 19 | /** 20 | * Colors to use on validation charts to indicate individual data series. 21 | */ 22 | export const CHART_COLORS: string[] = [ 23 | blue, 24 | green, 25 | red, 26 | orange, 27 | pink, 28 | purple, 29 | lime, 30 | teal, 31 | ].map((color) => color[500]); 32 | 33 | /** 34 | * Default validation settings for indoor and outdoor shows if the show file 35 | * does not specify validation settings. 36 | */ 37 | export const DEFAULT_VALIDATION_SETTINGS: Record< 38 | string, 39 | Readonly 40 | > = { 41 | indoor: Object.freeze({ 42 | maxAltitude: 6, 43 | maxVelocityXY: 2.5, 44 | maxVelocityZ: 1, 45 | minDistance: 0.5, 46 | }), 47 | outdoor: Object.freeze({ 48 | maxAltitude: 150, 49 | maxVelocityXY: 10, 50 | maxVelocityZ: 2, 51 | minDistance: 3, 52 | }), 53 | }; 54 | -------------------------------------------------------------------------------- /src/aframe/components/glow-material.js: -------------------------------------------------------------------------------- 1 | import AFrame from '@skybrush/aframe-components'; 2 | 3 | import GlowingMaterial from '../materials/GlowingMaterial'; 4 | 5 | AFrame.registerComponent('glow-material', { 6 | schema: { 7 | color: { type: 'color', is: 'uniform', default: '#0080ff' }, 8 | falloff: { type: 'number', is: 'uniform', default: 0.1 }, 9 | internalRadius: { type: 'number', is: 'uniform', default: 6 }, 10 | sharpness: { type: 'number', is: 'uniform', default: 1 }, 11 | opacity: { type: 'number', is: 'uniform', default: 1 }, 12 | }, 13 | 14 | init() { 15 | this.material = new GlowingMaterial(this._getMaterialProperties()); 16 | this.el.addEventListener('loaded', () => { 17 | const mesh = this.el.getObject3D('mesh'); 18 | if (mesh) { 19 | mesh.material = this.material; 20 | } 21 | }); 22 | }, 23 | 24 | update() { 25 | this.material?.setValues(this._getMaterialProperties()); 26 | }, 27 | 28 | _getMaterialProperties() { 29 | const { color, falloff, internalRadius, sharpness, opacity } = this.data; 30 | return { 31 | color, 32 | falloff, 33 | internalRadius, 34 | sharpness, 35 | opacity, 36 | }; 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /src/features/sidebar/slice.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Slice of the state object that stores the state of the sidebar. 3 | */ 4 | 5 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; 6 | import { noPayload } from '@skybrush/redux-toolkit'; 7 | import { SidebarTab } from './types'; 8 | 9 | type SidebarSliceState = { 10 | open: boolean; 11 | activeTab: SidebarTab; 12 | }; 13 | 14 | const initialState: SidebarSliceState = { 15 | open: false, 16 | activeTab: SidebarTab.INSPECTOR, 17 | }; 18 | 19 | const { actions, reducer, selectors } = createSlice({ 20 | name: 'sidebar', 21 | initialState, 22 | reducers: { 23 | closeSidebar: noPayload((state) => { 24 | state.open = false; 25 | }), 26 | 27 | toggleSidebar: noPayload((state) => { 28 | state.open = !state.open; 29 | }), 30 | 31 | setActiveSidebarTab: (state, action: PayloadAction) => { 32 | state.activeTab = action.payload; 33 | }, 34 | }, 35 | selectors: { 36 | isSidebarOpen: (state: SidebarSliceState) => state.open, 37 | }, 38 | }); 39 | 40 | export const { closeSidebar, setActiveSidebarTab, toggleSidebar } = actions; 41 | export const { isSidebarOpen } = selectors; 42 | 43 | export default reducer; 44 | -------------------------------------------------------------------------------- /src/components/WindowTitleManager.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getShowTitle, hasLoadedShowFile } from '~/features/show/selectors'; 5 | import { isElectronWindow } from '~/window'; 6 | 7 | import type { RootState } from '~/store'; 8 | 9 | type WindowTitleManagerProps = { 10 | readonly appName: string; 11 | readonly showTitle: string; 12 | }; 13 | 14 | const WindowTitleManager = ({ 15 | appName, 16 | showTitle, 17 | }: WindowTitleManagerProps) => { 18 | useEffect(() => { 19 | if (isElectronWindow(window)) { 20 | // Running inside Electron, use the bridge API to ask the renderer 21 | // process to change the window title. 22 | window.bridge.setTitle({ appName }); 23 | } else { 24 | // Running inside the browser, set the title of the document 25 | document.title = showTitle ? `${showTitle} | ${appName}` : appName; 26 | } 27 | }, [appName, showTitle]); 28 | 29 | return null; 30 | }; 31 | 32 | export default connect( 33 | // mapStateToProps 34 | (state: RootState) => ({ 35 | showTitle: hasLoadedShowFile(state) ? getShowTitle(state) : '', 36 | }), 37 | // mapDispatchToProps 38 | {} 39 | )(WindowTitleManager); 40 | -------------------------------------------------------------------------------- /webpack/helpers.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | 3 | const projectRoot = path.resolve(__dirname, '..'); 4 | const outputDir = path.resolve(projectRoot, 'build'); 5 | 6 | const isDevelopment = process.env.NODE_ENV !== 'production'; 7 | const useHotModuleReloading = isDevelopment && process.env.DEPLOYMENT !== '1'; 8 | 9 | const getHtmlMetaTags = ({ disableCSP = false } = {}) => { 10 | const result = { 11 | charset: 'utf-8', 12 | description: 13 | 'Skybrush Viewer: The Next-generation Drone Light Show Software Suite', 14 | viewport: 15 | 'initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no', 16 | 'X-UA-Compatible': 'IE=edge', 17 | }; 18 | 19 | if (!disableCSP) { 20 | result['Content-Security-Policy'] = 21 | "script-src 'self'; connect-src * ws: wss:;"; 22 | } 23 | 24 | return result; 25 | }; 26 | 27 | const useAppConfiguration = (name = 'default') => ({ 28 | resolve: { 29 | alias: { 30 | 'config-overrides': path.resolve(projectRoot, 'config', name), 31 | }, 32 | }, 33 | }); 34 | 35 | module.exports = { 36 | getHtmlMetaTags, 37 | isDevelopment, 38 | projectRoot, 39 | outputDir, 40 | useAppConfiguration, 41 | useHotModuleReloading, 42 | }; 43 | -------------------------------------------------------------------------------- /src/features/ui/actions.ts: -------------------------------------------------------------------------------- 1 | import type { AppThunk } from '~/store'; 2 | 3 | import { UIMode } from './modes'; 4 | import { getCurrentMode } from './selectors'; 5 | import { _setMode } from './slice'; 6 | import { rememberCameraPose } from '../three-d/slice'; 7 | 8 | /** 9 | * Action that switches the current mode of the UI. 10 | */ 11 | export const setMode = 12 | (mode: UIMode): AppThunk => 13 | (dispatch, getState) => { 14 | const currentMode = getCurrentMode(getState()); 15 | 16 | /* When leaving the player view, remember the current camera pose so we can 17 | * restore it when switching back to the player. */ 18 | if (currentMode === UIMode.PLAYER) { 19 | dispatch(rememberCameraPose()); 20 | } 21 | 22 | dispatch(_setMode(mode)); 23 | }; 24 | 25 | /** 26 | * Action that switches to the given mode when it is not the active mode yet, 27 | * or switches back to player mode if the given mode is already the active one. 28 | */ 29 | export const toggleMode = 30 | (mode: UIMode): AppThunk => 31 | (dispatch, getState) => { 32 | const state = getState(); 33 | if (getCurrentMode(state) === mode) { 34 | dispatch(setMode(UIMode.PLAYER)); 35 | } else { 36 | dispatch(setMode(mode)); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /patches/aframe-environment-component+1.5.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/aframe-environment-component/index.js b/node_modules/aframe-environment-component/index.js 2 | index 820e5ec..00b3ce5 100644 3 | --- a/node_modules/aframe-environment-component/index.js 4 | +++ b/node_modules/aframe-environment-component/index.js 5 | @@ -258,7 +258,6 @@ AFRAME.registerComponent('environment', { 6 | Object.assign(this.environmentData, this.data); 7 | Object.assign(this.environmentData, this.presets[this.data.preset]); 8 | Object.assign(this.environmentData, this.el.components.environment.attrValue); 9 | - console.log(this.environmentData); 10 | } 11 | 12 | var skyType = this.environmentData.skyType; 13 | @@ -453,7 +452,6 @@ AFRAME.registerComponent('environment', { 14 | str += ', '; 15 | } 16 | str += '}'; 17 | - console.log(str); 18 | }, 19 | 20 | // dumps current component settings to console. 21 | @@ -497,7 +495,6 @@ AFRAME.registerComponent('environment', { 22 | } 23 | } 24 | } 25 | - console.log('%c' + params.join('; '), 'color: #f48;font-weight:bold'); 26 | }, 27 | 28 | // Custom Math.random() with seed. Given this.environmentData.seed and x, it always returns the same "random" number 29 | -------------------------------------------------------------------------------- /src/features/hotkeys/ShowHotkeysDialogButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | import { Keyboard } from '@mui/icons-material'; 4 | import IconButton, { type IconButtonProps } from '@mui/material/IconButton'; 5 | import { Tooltip } from '@skybrush/mui-components'; 6 | 7 | import { useAppDispatch } from '~/hooks/store'; 8 | import { showHotkeyDialog } from './slice'; 9 | 10 | type ToggleValidationModeButtonProps = IconButtonProps & { 11 | readonly onToggleValidationMode?: () => void; 12 | readonly trajectoriesValid?: boolean; 13 | readonly validationInProgress?: boolean; 14 | }; 15 | 16 | const ShowHotkeysDialogButton = ({ 17 | onToggleValidationMode, 18 | trajectoriesValid, 19 | validationInProgress, 20 | ...rest 21 | }: ToggleValidationModeButtonProps) => { 22 | const { t } = useTranslation(); 23 | const dispatch = useAppDispatch(); 24 | return ( 25 | 26 | { 29 | dispatch(showHotkeyDialog()); 30 | }} 31 | {...rest} 32 | size='large' 33 | > 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default ShowHotkeysDialogButton; 41 | -------------------------------------------------------------------------------- /src/features/ui/slice.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Slice of the state object that stores the main settings of the user 3 | * interface that do not belong elsewhere. 4 | */ 5 | 6 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; 7 | 8 | import config from 'config'; 9 | 10 | import { RECENT_FILE_COUNT } from './constants'; 11 | import { MODES, UIMode } from './modes'; 12 | 13 | type UISliceState = { 14 | mode: UIMode; 15 | recentFiles: string[]; 16 | }; 17 | 18 | const initialState: UISliceState = { 19 | mode: UIMode.PLAYER, 20 | recentFiles: [], 21 | }; 22 | 23 | const { actions, reducer } = createSlice({ 24 | name: 'ui', 25 | initialState, 26 | reducers: { 27 | addRecentFile(state, action: PayloadAction) { 28 | const { payload } = action; 29 | 30 | state.recentFiles = [ 31 | payload, 32 | ...state.recentFiles.filter((rf) => rf !== payload), 33 | ].slice(0, RECENT_FILE_COUNT); 34 | }, 35 | 36 | _setMode(state, action: PayloadAction) { 37 | const { payload } = action; 38 | 39 | if (MODES.includes(payload) && config.modes[payload]) { 40 | state.mode = payload; 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | export const { addRecentFile, _setMode } = actions; 47 | 48 | export default reducer; 49 | -------------------------------------------------------------------------------- /src/features/validation/HorizontalVelocityChartPanel.tsx: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getTimestampFormatter } from '~/features/show/selectors'; 5 | import type { RootState } from '~/store'; 6 | 7 | import ChartPanel from './ChartPanel'; 8 | import { 9 | getHorizontalVelocityThreshold, 10 | getSampledHorizontalVelocitiesForDrones, 11 | } from './selectors'; 12 | import { createChartDataSelector } from './utils'; 13 | 14 | const getDataForHorizontalVelocityChart = createChartDataSelector( 15 | getSampledHorizontalVelocitiesForDrones 16 | ); 17 | 18 | // Custom range to use for the chart panel. This prevents the annotations from 19 | // affecting the range chosen by Chart.js but it will still allow the data to 20 | // expand the range if needed. 21 | const Y_RANGE: [number, number] = [0, 1]; 22 | 23 | export default connect( 24 | // mapStateToProps 25 | (state: RootState) => ({ 26 | data: getDataForHorizontalVelocityChart(state), 27 | formatPlaybackTimestamp: getTimestampFormatter(state), 28 | range: Y_RANGE, 29 | threshold: getHorizontalVelocityThreshold(state), 30 | thresholdLabel: t('validation.horizontalVelocityThreshold'), 31 | title: t('validation.horizontalVelocity'), 32 | verticalUnit: ' m/s', 33 | }), 34 | // mapDispatchToProps 35 | {} 36 | )(ChartPanel); 37 | -------------------------------------------------------------------------------- /src/views/player/CameraSelectorChip.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import CameraSelectorChip from '~/components/CameraSelectorChip'; 4 | import { cameraTriggerActions, keyMap } from '~/features/hotkeys/keymap'; 5 | import { getPerspectiveCamerasAndDefaultCamera } from '~/features/show/selectors'; 6 | import { switchToCameraByIndex } from '~/features/three-d/actions'; 7 | import { getSelectedCameraIndex } from '~/features/three-d/selectors'; 8 | import type { RootState } from '~/store'; 9 | 10 | const getFirstSequence = (keyMapItem: { 11 | sequence?: string; 12 | sequences?: string[]; 13 | }) => { 14 | if ('sequence' in keyMapItem) { 15 | return keyMapItem.sequence; 16 | } 17 | if ( 18 | 'sequences' in keyMapItem && 19 | Array.isArray(keyMapItem.sequences) && 20 | keyMapItem.sequences.length > 0 21 | ) { 22 | return keyMapItem.sequences[0]; 23 | } 24 | }; 25 | 26 | const HOTKEYS = cameraTriggerActions.map( 27 | (action) => getFirstSequence(keyMap[action]) ?? '' 28 | ); 29 | 30 | export default connect( 31 | // mapStateToProps 32 | (state: RootState) => ({ 33 | cameras: getPerspectiveCamerasAndDefaultCamera(state), 34 | hotkeys: HOTKEYS, 35 | selectedCameraIndex: getSelectedCameraIndex(state), 36 | }), 37 | // mapDispatchToProps 38 | { 39 | onCameraSelected: switchToCameraByIndex, 40 | } 41 | )(CameraSelectorChip); 42 | -------------------------------------------------------------------------------- /src/features/validation/HorizontalAccelerationChartPanel.tsx: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getTimestampFormatter } from '~/features/show/selectors'; 5 | import type { RootState } from '~/store'; 6 | 7 | import ChartPanel from './ChartPanel'; 8 | import { 9 | getHorizontalAccelerationThreshold, 10 | getSampledHorizontalAccelerationsForDrones, 11 | } from './selectors'; 12 | import { createChartDataSelector } from './utils'; 13 | 14 | const getDataForHorizontalAccelerationChart = createChartDataSelector( 15 | getSampledHorizontalAccelerationsForDrones 16 | ); 17 | 18 | // Custom range to use for the chart panel. This prevents the annotations from 19 | // affecting the range chosen by Chart.js but it will still allow the data to 20 | // expand the range if needed. 21 | const Y_RANGE: [number, number] = [-1, 1]; 22 | 23 | export default connect( 24 | // mapStateToProps 25 | (state: RootState) => ({ 26 | data: getDataForHorizontalAccelerationChart(state), 27 | formatPlaybackTimestamp: getTimestampFormatter(state), 28 | range: Y_RANGE, 29 | threshold: getHorizontalAccelerationThreshold(state), 30 | thresholdLabel: t('validation.horizontalAccelerationThreshold'), 31 | title: t('validation.horizontalAcceleration'), 32 | verticalUnit: ' m/s²', 33 | }), 34 | // mapDispatchToProps 35 | {} 36 | )(ChartPanel); 37 | -------------------------------------------------------------------------------- /src/features/validation/AltitudeChartPanel.tsx: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getTimestampFormatter, isShowIndoor } from '~/features/show/selectors'; 5 | import type { RootState } from '~/store'; 6 | 7 | import ChartPanel from './ChartPanel'; 8 | import { 9 | getAltitudeWarningThreshold, 10 | getSampledAltitudesForDrones, 11 | } from './selectors'; 12 | import { createChartDataSelector } from './utils'; 13 | 14 | const getDataForAltitudeChart = createChartDataSelector( 15 | getSampledAltitudesForDrones 16 | ); 17 | 18 | // Custom ranges to use for the chart panel. This prevents the annotations from 19 | // affecting the range chosen by Chart.js but it will still allow the data to 20 | // expand the range if needed. 21 | const Y_RANGE_INDOOR: [number, number] = [0, 2]; 22 | const Y_RANGE_OUTDOOR: [number, number] = [0, 10]; 23 | 24 | export default connect( 25 | // mapStateToProps 26 | (state: RootState) => ({ 27 | data: getDataForAltitudeChart(state), 28 | formatPlaybackTimestamp: getTimestampFormatter(state), 29 | range: isShowIndoor(state) ? Y_RANGE_INDOOR : Y_RANGE_OUTDOOR, 30 | threshold: getAltitudeWarningThreshold(state), 31 | thresholdLabel: t('validation.altitudeThreshold'), 32 | title: t('validation.altitude'), 33 | verticalUnit: ' m', 34 | }), 35 | // mapDispatchToProps 36 | {} 37 | )(ChartPanel); 38 | -------------------------------------------------------------------------------- /src/features/show/async.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import { 3 | CameraType, 4 | type Camera, 5 | type ShowSpecification, 6 | } from '@skybrush/show-format'; 7 | 8 | import { SHARED_CAMERA_NAME_PLACEHOLDER } from '~/constants'; 9 | 10 | import type { ShowLoadingRequest } from './types'; 11 | 12 | export const _doLoadShow = createAsyncThunk( 13 | 'show/load', 14 | async ({ 15 | initialCameraPose, 16 | show, 17 | }: ShowLoadingRequest): Promise => { 18 | if (typeof show === 'function') { 19 | show = await show(); 20 | } 21 | 22 | if (initialCameraPose) { 23 | // User specified an initial camera pose, let's add it to the show 24 | const initialCamera: Camera = { 25 | type: CameraType.PERSPECTIVE, 26 | name: SHARED_CAMERA_NAME_PLACEHOLDER, 27 | position: initialCameraPose.position, 28 | orientation: initialCameraPose.orientation, 29 | default: true, 30 | }; 31 | show = { 32 | ...show, 33 | environment: { 34 | ...show?.environment, 35 | cameras: [initialCamera, ...(show?.environment?.cameras ?? [])], 36 | }, 37 | }; 38 | } 39 | 40 | return show; 41 | } 42 | ); 43 | 44 | export const withProgressIndicator = createAsyncThunk( 45 | 'show/withProgressIndicator', 46 | (promise: any): Promise => promise 47 | ); 48 | -------------------------------------------------------------------------------- /src/features/settings/DroneSizeSlider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | import Slider from '@mui/material/Slider'; 4 | import Typography from '@mui/material/Typography'; 5 | 6 | import { setDroneRadius } from '~/features/settings/actions'; 7 | import { getRawDroneRadiusSetting } from '~/features/settings/selectors'; 8 | import { useAppDispatch, useAppSelector } from '~/hooks/store'; 9 | 10 | const labelFormatter = (value: number) => `${value}x`; 11 | 12 | /** 13 | * Slider that allows the user to set the sizes of the drones on the UI. 14 | */ 15 | const DroneSizeSlider = () => { 16 | const droneRadius = useAppSelector(getRawDroneRadiusSetting); 17 | const dispatch = useAppDispatch(); 18 | const { t } = useTranslation(); 19 | 20 | return ( 21 | <> 22 | {t('settings.droneRadius')} 23 | { 32 | const radius = Array.isArray(value) ? value[0] : value; 33 | if (radius !== undefined) { 34 | dispatch(setDroneRadius(radius)); 35 | } 36 | }} 37 | /> 38 | 39 | ); 40 | }; 41 | 42 | export default DroneSizeSlider; 43 | -------------------------------------------------------------------------------- /src/components/MainTopLevelView.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Component that shows a three-dimensional view of the drone flock. 3 | */ 4 | 5 | import React, { Suspense, useRef, type RefObject } from 'react'; 6 | import { connect } from 'react-redux'; 7 | 8 | import Box from '@mui/material/Box'; 9 | 10 | import { UIMode } from '~/features/ui/modes'; 11 | import { getCurrentMode } from '~/features/ui/selectors'; 12 | import type { RootState } from '~/store'; 13 | import PlayerView from '~/views/player'; 14 | 15 | import PageLoadingIndicator from './PageLoadingIndicator'; 16 | 17 | const LazyValidationView = React.lazy( 18 | async () => import(/* webpackChunkName: "validation" */ '~/views/validation') 19 | ); 20 | 21 | type MainTopLevelViewProps = { 22 | readonly mode: UIMode; 23 | }; 24 | 25 | const MainTopLevelView = ({ mode }: MainTopLevelViewProps) => { 26 | const ref: RefObject = useRef(null) as any; 27 | 28 | return ( 29 | 30 | }> 31 | {mode === UIMode.PLAYER && } 32 | {mode === UIMode.VALIDATION && } 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default connect( 39 | // mapStateToProps 40 | (state: RootState) => ({ 41 | mode: getCurrentMode(state), 42 | }), 43 | // mapDispatchToProps 44 | {} 45 | )(MainTopLevelView); 46 | -------------------------------------------------------------------------------- /src/features/settings/LanguageSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | import FormControl from '@mui/material/FormControl'; 4 | import InputLabel from '@mui/material/InputLabel'; 5 | import MenuItem from '@mui/material/MenuItem'; 6 | import Select from '@mui/material/Select'; 7 | 8 | import { useAppDispatch, useAppSelector } from '~/hooks/store'; 9 | import { enabledLanguages } from '~/i18n'; 10 | 11 | import { setLanguage } from './actions'; 12 | import { getLanguage } from './selectors'; 13 | 14 | /** 15 | * Component for selecting the playback speed. 16 | */ 17 | const LanguageSelector = () => { 18 | const dispatch = useAppDispatch(); 19 | const language = useAppSelector(getLanguage); 20 | const { t } = useTranslation(); 21 | 22 | return ( 23 | 24 | 25 | {t('settings.language')} 26 | 27 | 41 | 42 | ); 43 | }; 44 | 45 | export default LanguageSelector; 46 | -------------------------------------------------------------------------------- /src/features/validation/VerticalVelocityChartPanel.tsx: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getTimestampFormatter } from '~/features/show/selectors'; 5 | import type { RootState } from '~/store'; 6 | 7 | import ChartPanel from './ChartPanel'; 8 | import { 9 | getSampledVerticalVelocitiesForDrones, 10 | getVerticalVelocityThresholdDown, 11 | getVerticalVelocityThresholdUp, 12 | } from './selectors'; 13 | import { createChartDataSelector } from './utils'; 14 | 15 | const getDataForVerticalVelocityChart = createChartDataSelector( 16 | getSampledVerticalVelocitiesForDrones 17 | ); 18 | 19 | // Custom range to use for the chart panel. This prevents the annotations from 20 | // affecting the range chosen by Chart.js but it will still allow the data to 21 | // expand the range if needed. 22 | const Y_RANGE: [number, number] = [-1, 1]; 23 | 24 | export default connect( 25 | // mapStateToProps 26 | (state: RootState) => ({ 27 | data: getDataForVerticalVelocityChart(state), 28 | formatPlaybackTimestamp: getTimestampFormatter(state), 29 | range: Y_RANGE, 30 | threshold: [ 31 | getVerticalVelocityThresholdUp(state), 32 | -getVerticalVelocityThresholdDown(state), 33 | ], 34 | thresholdLabel: t('validation.verticalVelocityThreshold'), 35 | title: t('validation.verticalVelocity'), 36 | verticalUnit: ' m/s', 37 | }), 38 | // mapDispatchToProps 39 | {} 40 | )(ChartPanel); 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skybrush Viewer 2 | 3 | This repo contains the source code of Skybrush Viewer, the viewer app for 4 | Skybrush drone shows. 5 | 6 | ## Usage 7 | 8 | Make sure that you are using a recent LTS Node.js release, then run the 9 | following commands: 10 | 11 | ``` 12 | npm install 13 | npm run start:electron 14 | ``` 15 | 16 | You may also run the app inside a browser environment: 17 | 18 | ``` 19 | npm install 20 | npm run start 21 | ``` 22 | 23 | Navigate to `http://localhost:8080` after startup to use the app in 24 | a browser. Note that not all features may be available in a browser 25 | environment. 26 | 27 | ## Support 28 | 29 | For any support questions please contact us on our [Discord server](https://skybrush.io/r/discord). 30 | 31 | ## License 32 | 33 | Copyright 2020-2024 CollMot Robotics Ltd. 34 | 35 | Skybrush Viewer is free software: you can redistribute it and/or modify it under 36 | the terms of the GNU General Public License as published by the Free Software 37 | Foundation, either version 3 of the License, or (at your option) any later 38 | version. 39 | 40 | Skybrush Viewer is distributed in the hope that it will be useful, but WITHOUT 41 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 42 | FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 43 | more details. 44 | 45 | You should have received a copy of the GNU General Public License along with 46 | this program. If not, see . 47 | -------------------------------------------------------------------------------- /src/views/player/PlaybackSlider.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import { PlaybackSlider } from '@skybrush/mui-components'; 4 | 5 | import { 6 | setPlaybackPosition, 7 | temporarilyOverridePlaybackPosition, 8 | } from '~/features/playback/actions'; 9 | import { 10 | getElapsedSecondsGetter, 11 | isAdjustingPlaybackPosition, 12 | isPlaying, 13 | } from '~/features/playback/selectors'; 14 | import { getPlaybackSliderStepSize } from '~/features/settings/selectors'; 15 | import { 16 | getMarksFromShowCues, 17 | getShowDuration, 18 | getTimestampFormatter, 19 | } from '~/features/show/selectors'; 20 | import type { RootState } from '~/store'; 21 | 22 | export default connect( 23 | // mapStateToProps 24 | (state: RootState) => { 25 | const step = getPlaybackSliderStepSize(state); 26 | return { 27 | dragging: isAdjustingPlaybackPosition(state), 28 | duration: getShowDuration(state), 29 | formatPlaybackTimestamp: getTimestampFormatter(state), 30 | getElapsedSeconds: getElapsedSecondsGetter(state), 31 | marks: getMarksFromShowCues(state), 32 | playing: isPlaying(state), 33 | step, 34 | shiftStep: step, 35 | }; 36 | }, 37 | // mapDispatchToProps 38 | { 39 | onDragged: (event: any, value: number | number[]) => 40 | setPlaybackPosition(Array.isArray(value) ? value[0] : value), 41 | onDragging: (event: any, value: number | number[]) => 42 | temporarilyOverridePlaybackPosition( 43 | Array.isArray(value) ? value[0] : value 44 | ), 45 | } 46 | )(PlaybackSlider); 47 | -------------------------------------------------------------------------------- /src/features/validation/VerticalAccelerationChartPanel.tsx: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { getTimestampFormatter } from '~/features/show/selectors'; 5 | import type { RootState } from '~/store'; 6 | 7 | import ChartPanel from './ChartPanel'; 8 | import { 9 | getSampledVerticalAccelerationsForDrones, 10 | getVerticalAccelerationThresholdDown, 11 | getVerticalAccelerationThresholdUp, 12 | } from './selectors'; 13 | import { createChartDataSelector } from './utils'; 14 | 15 | const getDataForVerticalAccelerationChart = createChartDataSelector( 16 | getSampledVerticalAccelerationsForDrones 17 | ); 18 | 19 | const maybeNegate = (x?: number) => (typeof x === 'number' ? -x : x); 20 | 21 | // Custom range to use for the chart panel. This prevents the annotations from 22 | // affecting the range chosen by Chart.js but it will still allow the data to 23 | // expand the range if needed. 24 | const Y_RANGE: [number, number] = [-1, 1]; 25 | 26 | export default connect( 27 | // mapStateToProps 28 | (state: RootState) => ({ 29 | data: getDataForVerticalAccelerationChart(state), 30 | formatPlaybackTimestamp: getTimestampFormatter(state), 31 | range: Y_RANGE, 32 | threshold: [ 33 | getVerticalAccelerationThresholdUp(state), 34 | maybeNegate(getVerticalAccelerationThresholdDown(state)), 35 | ], 36 | thresholdLabel: t('validation.verticalAccelerationThreshold'), 37 | title: t('validation.verticalAcceleration'), 38 | verticalUnit: ' m/s²', 39 | }), 40 | // mapDispatchToProps 41 | {} 42 | )(ChartPanel); 43 | -------------------------------------------------------------------------------- /src/views/player/Overlays.tsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import Box from '@mui/material/Box'; 4 | import Fade from '@mui/material/Fade'; 5 | import { styled } from '@mui/material/styles'; 6 | 7 | import { PLAYER_SIDEBAR_WIDTH } from '~/constants'; 8 | import { isSidebarOpen } from '~/features/sidebar/slice'; 9 | import { useAppSelector } from '~/hooks/store'; 10 | import type { RootState } from '~/store'; 11 | 12 | import BottomOverlay from './BottomOverlay'; 13 | import TopOverlay from './TopOverlay'; 14 | 15 | const OverlayContainer = styled(Box, { 16 | shouldForwardProp: (prop) => prop !== 'sidebarOpen', 17 | })<{ sidebarOpen: boolean }>(({ sidebarOpen, theme }) => ({ 18 | position: 'absolute', 19 | top: 0, 20 | left: 0, 21 | right: sidebarOpen ? PLAYER_SIDEBAR_WIDTH : 0, 22 | bottom: 0, 23 | transition: theme.transitions.create(['right'], { 24 | duration: theme.transitions.duration.enteringScreen, 25 | }), 26 | pointerEvents: 'none', 27 | })); 28 | 29 | const Overlays = ({ visible = false }: { readonly visible: boolean }) => { 30 | const sidebarOpen = useAppSelector(isSidebarOpen); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default connect( 45 | // mapStateToProps 46 | (state: RootState) => ({ 47 | visible: state.threeD.overlays.visible, 48 | }), 49 | // mapDispatchToProps 50 | {} 51 | )(Overlays); 52 | -------------------------------------------------------------------------------- /src/features/three-d/actions.ts: -------------------------------------------------------------------------------- 1 | import { getElapsedSeconds } from '~/features/playback/selectors'; 2 | import { getCenterOfBoundingBoxOfDronesAt } from '~/features/show/selectors'; 3 | import { type AppThunk } from '~/store'; 4 | 5 | import { 6 | resetZoom as _resetZoom, 7 | rotateViewTowards, 8 | setOverlayVisibility, 9 | setSelectedCameraIndex, 10 | switchToSelectedCamera, 11 | } from './slice'; 12 | 13 | export const resetZoom = (): AppThunk => (dispatch) => { 14 | dispatch(_resetZoom()); 15 | 16 | // Make the button that triggered the action lose focus so it won't be triggered 17 | // if the user presses Space to start playback 18 | document.body.focus(); 19 | }; 20 | 21 | export const rotateViewToDrones = (): AppThunk => (dispatch, getState) => { 22 | const state = getState(); 23 | const time = getElapsedSeconds(state); 24 | const center = getCenterOfBoundingBoxOfDronesAt(state, time); 25 | if (center) { 26 | dispatch(rotateViewTowards(center)); 27 | } 28 | 29 | // Make the button that triggered the action lose focus so it won't be triggered 30 | // if the user presses Space to start playback 31 | document.body.focus(); 32 | }; 33 | 34 | export const switchToCameraByIndex = 35 | (index: number): AppThunk => 36 | (dispatch) => { 37 | dispatch(setSelectedCameraIndex(index)); 38 | dispatch(switchToSelectedCamera()); 39 | }; 40 | 41 | export const setOverlayHidden = (): AppThunk => (dispatch) => { 42 | dispatch(setOverlayVisibility(false)); 43 | }; 44 | 45 | export const setOverlayVisible = (): AppThunk => (dispatch) => { 46 | dispatch(setOverlayVisibility(true)); 47 | }; 48 | -------------------------------------------------------------------------------- /src/views/player/sidebar/CueSheetAccordion.tsx: -------------------------------------------------------------------------------- 1 | import { MiniList, MiniListItemButton } from '@skybrush/mui-components'; 2 | import { type Cue } from '@skybrush/show-format'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { getCues } from '~/features/show/selectors'; 5 | import { useAppDispatch, useAppSelector } from '~/hooks/store'; 6 | import { formatPlaybackTimestamp } from '~/utils/formatters'; 7 | 8 | import { setPlaybackPosition } from '~/features/playback/actions'; 9 | import { Accordion, AccordionDetails, AccordionSummary } from './Accordion'; 10 | 11 | const CueSheetSection = ({ cues }: { cues: readonly Cue[] }) => { 12 | const dispatch = useAppDispatch(); 13 | 14 | return ( 15 | 16 | {cues.map((cue, index) => ( 17 | { 19 | dispatch(setPlaybackPosition(cue.time)); 20 | }} 21 | key={index} 22 | primaryText={cue.name} 23 | secondaryText={formatPlaybackTimestamp(cue.time)} 24 | /> 25 | ))} 26 | 27 | ); 28 | }; 29 | 30 | export default function CueListAccordion() { 31 | const cues = useAppSelector(getCues); 32 | const { t } = useTranslation(); 33 | 34 | if (cues.length === 0) { 35 | return null; // Don't render the accordion if there are no cues 36 | } else { 37 | return ( 38 | 39 | {t('inspector.cueSheet.summary')} 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/desktop/launcher/window-title.mjs: -------------------------------------------------------------------------------- 1 | import { isNil } from 'lodash-es'; 2 | import path from 'node:path'; 3 | 4 | import { isRunningOnMac } from './utils.mjs'; 5 | 6 | const appNames = new WeakMap(); 7 | const representedFiles = new WeakMap(); 8 | 9 | /** 10 | * @param {Electron.BrowserWindow} window 11 | * @param {{ appName?: string, representedFile?: string, alternateFile?: string }} options 12 | */ 13 | export const setTitle = (window, options) => { 14 | let { appName, representedFile, alternateFile } = options ?? {}; 15 | 16 | if (appName !== undefined) { 17 | appNames.set(window, isNil(appName) ? '' : String(appName)); 18 | } 19 | 20 | if (representedFile !== undefined) { 21 | representedFiles.set( 22 | window, 23 | isNil(representedFile) 24 | ? isNil(alternateFile) 25 | ? '' 26 | : '@' + String(alternateFile) 27 | : String(representedFile) 28 | ); 29 | } 30 | 31 | appName = appNames.get(window); 32 | representedFile = representedFiles.get(window); 33 | const filename = representedFile ? path.basename(representedFile) : ''; 34 | 35 | if (isRunningOnMac) { 36 | if (filename) { 37 | window.setTitle(filename); 38 | if (representedFile.charAt(0) !== '@') { 39 | window.setRepresentedFilename(representedFile); 40 | } else { 41 | window.setRepresentedFilename(''); 42 | } 43 | } else { 44 | window.setTitle(appName); 45 | window.setRepresentedFilename(''); 46 | } 47 | } else { 48 | window.setTitle(filename ? `${filename} - ${appName}` : appName); 49 | } 50 | }; 51 | 52 | export default setTitle; 53 | -------------------------------------------------------------------------------- /src/desktop/preload/index.js: -------------------------------------------------------------------------------- 1 | const { contextBridge } = require('electron'); 2 | const { ipcRenderer: ipc } = require('electron-better-ipc'); 3 | 4 | const createStorageEngine = require('redux-persist-electron-storage'); 5 | 6 | const { receiveActionsFromRenderer, setupIpc } = require('./ipc'); 7 | 8 | /** 9 | * Creates a Redux state store object that stores the Redux state in an 10 | * Electron store. 11 | * 12 | * @return {Object} a Redux storage engine that can be used by redux-storage 13 | */ 14 | function createStateStore() { 15 | return createStorageEngine({ 16 | store: { 17 | name: 'state', 18 | }, 19 | }); 20 | } 21 | 22 | // Inject the bridge functions between the main and the renderer processes. 23 | // These are the only functions that the renderer processes may call to access 24 | // any functionality that requires Node.js -- they are not allowed to use 25 | // Node.js modules themselves 26 | contextBridge.exposeInMainWorld('bridge', { 27 | createStateStore, 28 | isElectron: true, 29 | getShowAsObjectFromLocalFile: (filename) => 30 | ipc.callMain('getShowAsObjectFromLocalFile', filename), 31 | provideActions(...args) { 32 | receiveActionsFromRenderer(...args); 33 | 34 | // Let the main process know that we are now ready to open show files 35 | ipc.callMain('readyForFileOpening'); 36 | }, 37 | selectLocalShowFileForOpening: () => 38 | ipc.callMain('selectLocalShowFileForOpening'), 39 | setTitle({ appName, representedFile }) { 40 | ipc.callMain('setTitle', { appName, representedFile }); 41 | }, 42 | }); 43 | 44 | // Set up IPC channels that we are going to listen to 45 | setupIpc(); 46 | -------------------------------------------------------------------------------- /src/features/audio/slice.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Slice of the state object that stores the state of the audio playback. 3 | */ 4 | 5 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; 6 | import { noPayload } from '@skybrush/redux-toolkit'; 7 | 8 | type AudioSliceState = { 9 | url: string | undefined; 10 | loading: boolean; 11 | seeking: boolean; 12 | muted: boolean; 13 | volume: number; 14 | }; 15 | 16 | const initialState: AudioSliceState = { 17 | url: undefined, 18 | loading: false, 19 | seeking: false, 20 | muted: false, 21 | volume: 1, 22 | }; 23 | 24 | const { actions, reducer } = createSlice({ 25 | name: 'audio', 26 | initialState, 27 | reducers: { 28 | notifyAudioCanPlay: noPayload((state) => { 29 | state.loading = false; 30 | }), 31 | 32 | notifyAudioMetadataLoaded: noPayload((state) => { 33 | state.loading = true; 34 | }), 35 | 36 | notifyAudioSeeked: noPayload((state) => { 37 | state.seeking = false; 38 | }), 39 | 40 | notifyAudioSeeking: noPayload((state) => { 41 | state.seeking = true; 42 | }), 43 | 44 | setAudioUrl(state, action: PayloadAction) { 45 | const { payload } = action; 46 | state.url = typeof payload === 'string' ? payload : undefined; 47 | }, 48 | 49 | toggleMuted: noPayload((state) => { 50 | state.muted = !state.muted; 51 | }), 52 | }, 53 | }); 54 | 55 | export const { 56 | notifyAudioCanPlay, 57 | notifyAudioMetadataLoaded, 58 | notifyAudioSeeked, 59 | notifyAudioSeeking, 60 | setAudioUrl, 61 | toggleMuted, 62 | } = actions; 63 | 64 | export default reducer; 65 | -------------------------------------------------------------------------------- /src/features/settings/DroneModelSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | import FormControl from '@mui/material/FormControl'; 4 | import InputLabel from '@mui/material/InputLabel'; 5 | import MenuItem from '@mui/material/MenuItem'; 6 | import Select from '@mui/material/Select'; 7 | 8 | import { useAppDispatch, useAppSelector } from '~/hooks/store'; 9 | import { setDroneModel } from './actions'; 10 | import { getDroneModel } from './selectors'; 11 | import { isValidDroneModelType } from './types'; 12 | 13 | /** 14 | * Component for selecting the playback speed. 15 | */ 16 | const DroneModelSelector = () => { 17 | const dispatch = useAppDispatch(); 18 | const scenery = useAppSelector(getDroneModel); 19 | const { t } = useTranslation(); 20 | return ( 21 | 22 | 23 | {t('settings.droneModel.label')} 24 | 25 | 40 | 41 | ); 42 | }; 43 | 44 | export default DroneModelSelector; 45 | -------------------------------------------------------------------------------- /src/desktop/launcher/media-buffers.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import tmp from 'tmp-promise'; 3 | 4 | /** 5 | * Variable that holds the currently loaded audio data. 6 | * 7 | * Each audio buffer slot is backed by a temporary file on the disk that holds 8 | * the actual buffer contents. This is because the HTML5