Repository: abstrakt8/rewind Branch: master Commit: d9d24182c893 Files: 507 Total size: 758.9 KB Directory structure: gitextract_icqr6nvq/ ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── build-release.yml ├── .gitignore ├── .gitmodules ├── .prettierignore ├── .prettierrc ├── .storybook/ │ ├── main.js │ ├── preview.js │ ├── tsconfig.json │ └── webpack.config.js ├── .vscode/ │ └── extensions.json ├── .yarnrc ├── CONTRIBUTION.md ├── LICENSE.md ├── README.md ├── apps/ │ ├── desktop/ │ │ ├── README.md │ │ ├── frontend/ │ │ │ ├── .babelrc │ │ │ ├── .browserslistrc │ │ │ ├── .eslintrc.json │ │ │ ├── .storybook/ │ │ │ │ ├── main.js │ │ │ │ ├── preview.js │ │ │ │ ├── tsconfig.json │ │ │ │ └── webpack.config.js │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── proxy.conf.json │ │ │ ├── src/ │ │ │ │ ├── app/ │ │ │ │ │ ├── RewindApp.tsx │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── analyzer/ │ │ │ │ │ │ │ ├── BaseAudioSettingsPanel.tsx │ │ │ │ │ │ │ ├── BaseCurrentTime.stories.tsx │ │ │ │ │ │ │ ├── BaseCurrentTime.tsx │ │ │ │ │ │ │ ├── BaseDialog.tsx │ │ │ │ │ │ │ ├── BaseGameTimeSlider.stories.tsx │ │ │ │ │ │ │ ├── BaseGameTimeSlider.tsx │ │ │ │ │ │ │ ├── BaseSettingsModal.stories.tsx │ │ │ │ │ │ │ ├── BaseSettingsModal.tsx │ │ │ │ │ │ │ ├── GameCanvas.tsx │ │ │ │ │ │ │ ├── HelpModal.stories.tsx │ │ │ │ │ │ │ ├── HelpModal.tsx │ │ │ │ │ │ │ ├── PlayBar.tsx │ │ │ │ │ │ │ └── SettingsModal.tsx │ │ │ │ │ │ ├── logo/ │ │ │ │ │ │ │ └── RewindLogo.tsx │ │ │ │ │ │ ├── sidebar/ │ │ │ │ │ │ │ └── LeftMenuSidebar.tsx │ │ │ │ │ │ └── update/ │ │ │ │ │ │ └── UpdateModal.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── app-info.ts │ │ │ │ │ │ ├── audio.ts │ │ │ │ │ │ ├── energy-saver.ts │ │ │ │ │ │ ├── game-clock.ts │ │ │ │ │ │ ├── interval.ts │ │ │ │ │ │ ├── mods.ts │ │ │ │ │ │ ├── redux.ts │ │ │ │ │ │ └── shortcuts.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── BlueprintInfo.ts │ │ │ │ │ │ ├── OsuReplay.ts │ │ │ │ │ │ ├── Skin.ts │ │ │ │ │ │ └── SkinId.ts │ │ │ │ │ ├── providers/ │ │ │ │ │ │ ├── SettingsProvider.tsx │ │ │ │ │ │ └── TheaterProvider.tsx │ │ │ │ │ ├── screens/ │ │ │ │ │ │ ├── analyzer/ │ │ │ │ │ │ │ └── Analyzer.tsx │ │ │ │ │ │ ├── home/ │ │ │ │ │ │ │ └── HomeScreen.tsx │ │ │ │ │ │ ├── setup/ │ │ │ │ │ │ │ └── SetupScreen.tsx │ │ │ │ │ │ └── splash/ │ │ │ │ │ │ └── SplashScreen.tsx │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── analysis/ │ │ │ │ │ │ │ ├── AnalysisApp.ts │ │ │ │ │ │ │ ├── analysis-cursor.ts │ │ │ │ │ │ │ ├── createRewindAnalysisApp.ts │ │ │ │ │ │ │ ├── mod-settings.ts │ │ │ │ │ │ │ ├── scenes/ │ │ │ │ │ │ │ │ ├── AnalysisScene.ts │ │ │ │ │ │ │ │ ├── IdleScene.ts │ │ │ │ │ │ │ │ ├── LoadingScene.ts │ │ │ │ │ │ │ │ └── ResultsScreenScene.ts │ │ │ │ │ │ │ └── screenshot.ts │ │ │ │ │ │ ├── common/ │ │ │ │ │ │ │ ├── CommonManagers.ts │ │ │ │ │ │ │ ├── app-info.ts │ │ │ │ │ │ │ ├── audio/ │ │ │ │ │ │ │ │ ├── AudioEngine.ts │ │ │ │ │ │ │ │ ├── AudioService.ts │ │ │ │ │ │ │ │ └── settings.ts │ │ │ │ │ │ │ ├── beatmap-background.ts │ │ │ │ │ │ │ ├── beatmap-render.ts │ │ │ │ │ │ │ ├── cursor.ts │ │ │ │ │ │ │ ├── game/ │ │ │ │ │ │ │ │ ├── GameLoop.ts │ │ │ │ │ │ │ │ ├── GameSimulator.ts │ │ │ │ │ │ │ │ └── GameplayClock.ts │ │ │ │ │ │ │ ├── hit-error-bar.ts │ │ │ │ │ │ │ ├── key-press-overlay.ts │ │ │ │ │ │ │ ├── local/ │ │ │ │ │ │ │ │ ├── BlueprintLocatorService.ts │ │ │ │ │ │ │ │ ├── OsuDBDao.ts │ │ │ │ │ │ │ │ ├── OsuFolderService.ts │ │ │ │ │ │ │ │ ├── ReplayFileWatcher.ts │ │ │ │ │ │ │ │ ├── ReplayService.ts │ │ │ │ │ │ │ │ └── SkinLoader.ts │ │ │ │ │ │ │ ├── local-storage.ts │ │ │ │ │ │ │ ├── playbar.ts │ │ │ │ │ │ │ ├── playfield-border.ts │ │ │ │ │ │ │ ├── replay-cursor.ts │ │ │ │ │ │ │ ├── scenes/ │ │ │ │ │ │ │ │ ├── IScene.ts │ │ │ │ │ │ │ │ └── SceneManager.ts │ │ │ │ │ │ │ └── skin.ts │ │ │ │ │ │ ├── core/ │ │ │ │ │ │ │ └── service.ts │ │ │ │ │ │ ├── manager/ │ │ │ │ │ │ │ ├── AnalysisSceneManager.ts │ │ │ │ │ │ │ ├── BeatmapManager.ts │ │ │ │ │ │ │ ├── ClipRecorder.ts │ │ │ │ │ │ │ ├── ReplayManager.ts │ │ │ │ │ │ │ └── ScenarioManager.ts │ │ │ │ │ │ ├── renderers/ │ │ │ │ │ │ │ ├── PixiRendererManager.ts │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── background/ │ │ │ │ │ │ │ │ │ └── BeatmapBackground.ts │ │ │ │ │ │ │ │ ├── hud/ │ │ │ │ │ │ │ │ │ └── ForegroundHUDPreparer.ts │ │ │ │ │ │ │ │ ├── keypresses/ │ │ │ │ │ │ │ │ │ └── KeyPressOverlay.ts │ │ │ │ │ │ │ │ ├── playfield/ │ │ │ │ │ │ │ │ │ ├── CursorPreparer.ts │ │ │ │ │ │ │ │ │ ├── HitCircleFactory.ts │ │ │ │ │ │ │ │ │ ├── HitObjectsContainerFactory.ts │ │ │ │ │ │ │ │ │ ├── JudgementPreparer.ts │ │ │ │ │ │ │ │ │ ├── PlayfieldBorderFactory.ts │ │ │ │ │ │ │ │ │ ├── PlayfieldFactory.ts │ │ │ │ │ │ │ │ │ ├── SliderFactory.ts │ │ │ │ │ │ │ │ │ └── SpinnerFactory.ts │ │ │ │ │ │ │ │ ├── sliders/ │ │ │ │ │ │ │ │ │ └── SliderTextureManager.ts │ │ │ │ │ │ │ │ └── stage/ │ │ │ │ │ │ │ │ └── AnalysisStage.ts │ │ │ │ │ │ │ └── constants.ts │ │ │ │ │ │ ├── textures/ │ │ │ │ │ │ │ └── TextureManager.ts │ │ │ │ │ │ └── types/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── store/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── settings/ │ │ │ │ │ │ │ └── slice.ts │ │ │ │ │ │ └── update/ │ │ │ │ │ │ └── slice.ts │ │ │ │ │ ├── styles/ │ │ │ │ │ │ └── theme.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── focus.ts │ │ │ │ │ ├── pooling/ │ │ │ │ │ │ ├── ObjectPool.ts │ │ │ │ │ │ └── TemporaryObjectPool.ts │ │ │ │ │ └── replay.ts │ │ │ │ ├── assets/ │ │ │ │ │ └── .gitkeep │ │ │ │ ├── constants.ts │ │ │ │ ├── environments/ │ │ │ │ │ ├── environment.prod.ts │ │ │ │ │ └── environment.ts │ │ │ │ ├── index.html │ │ │ │ ├── main.tsx │ │ │ │ ├── polyfills.ts │ │ │ │ └── styles.css │ │ │ ├── test/ │ │ │ │ └── ajv.spec.ts │ │ │ ├── tsconfig.app.json │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.spec.json │ │ │ └── webpack.config.js │ │ └── main/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── electron-builder.json │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── .gitkeep │ │ │ │ ├── config.ts │ │ │ │ ├── events.ts │ │ │ │ ├── updater.ts │ │ │ │ └── windows.ts │ │ │ ├── assets/ │ │ │ │ └── .gitkeep │ │ │ ├── environments/ │ │ │ │ ├── environment.prod.ts │ │ │ │ └── environment.ts │ │ │ └── index.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ └── web/ │ └── backend/ │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.ts │ ├── src/ │ │ ├── DesktopAPI.ts │ │ ├── api-common.module.ts │ │ ├── assets/ │ │ │ └── index.html │ │ ├── blueprints/ │ │ │ ├── BlueprintInfo.ts │ │ │ ├── LocalBlueprintController.ts │ │ │ ├── LocalBlueprintService.ts │ │ │ └── OsuDBDao.ts │ │ ├── config/ │ │ │ ├── DesktopConfigController.ts │ │ │ ├── DesktopConfigService.ts │ │ │ ├── UserConfigService.ts │ │ │ └── utils.ts │ │ ├── constants.ts │ │ ├── environments/ │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── events/ │ │ │ ├── Events.ts │ │ │ └── EventsGateway.ts │ │ ├── main.ts │ │ ├── replays/ │ │ │ ├── LocalReplayController.ts │ │ │ ├── LocalReplayService.ts │ │ │ ├── ReplayWatcher.ts │ │ │ └── ScoresDBDao.ts │ │ ├── skins/ │ │ │ ├── SkinController.ts │ │ │ ├── SkinNameResolver.ts │ │ │ └── SkinService.ts │ │ ├── status/ │ │ │ └── SetupStatusController.ts │ │ └── utils/ │ │ ├── names.spec.ts │ │ └── names.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json ├── babel.config.json ├── electron-builder.json ├── jest.config.ts ├── jest.preset.js ├── libs/ │ ├── @types/ │ │ └── node-osr/ │ │ └── index.d.ts │ ├── osu/ │ │ ├── core/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── audio/ │ │ │ │ │ ├── HitSampleInfo.ts │ │ │ │ │ └── LegacySampleBank.ts │ │ │ │ ├── beatmap/ │ │ │ │ │ ├── Beatmap.ts │ │ │ │ │ ├── BeatmapBuilder.ts │ │ │ │ │ ├── BeatmapDifficulty.ts │ │ │ │ │ ├── ControlPoints/ │ │ │ │ │ │ ├── ControlPoint.ts │ │ │ │ │ │ ├── ControlPointGroup.ts │ │ │ │ │ │ ├── ControlPointInfo.ts │ │ │ │ │ │ ├── DifficultyControlPoint.ts │ │ │ │ │ │ ├── EffectControlPoint.ts │ │ │ │ │ │ ├── SampleControlPoint.ts │ │ │ │ │ │ └── TimingControlPoint.ts │ │ │ │ │ ├── LegacyEffectFlag.ts │ │ │ │ │ └── TimeSignatures.ts │ │ │ │ ├── blueprint/ │ │ │ │ │ ├── Blueprint.ts │ │ │ │ │ ├── BlueprintParser.spec.ts │ │ │ │ │ ├── BlueprintParser.ts │ │ │ │ │ └── HitObjectSettings.ts │ │ │ │ ├── gameplay/ │ │ │ │ │ ├── GameState.ts │ │ │ │ │ ├── GameStateEvaluator.spec.ts │ │ │ │ │ ├── GameStateEvaluator.ts │ │ │ │ │ ├── GameStateTimeMachine.ts │ │ │ │ │ ├── GameplayAnalysisEvent.ts │ │ │ │ │ ├── GameplayInfo.ts │ │ │ │ │ └── Verdicts.ts │ │ │ │ ├── hitobjects/ │ │ │ │ │ ├── HitCircle.ts │ │ │ │ │ ├── Properties.ts │ │ │ │ │ ├── Slider.ts │ │ │ │ │ ├── SliderCheckPoint.ts │ │ │ │ │ ├── Spinner.ts │ │ │ │ │ ├── Types.ts │ │ │ │ │ └── slider/ │ │ │ │ │ ├── PathApproximator.ts │ │ │ │ │ ├── PathControlPoint.ts │ │ │ │ │ ├── PathType.ts │ │ │ │ │ ├── SliderCheckPointDescriptor.ts │ │ │ │ │ ├── SliderCheckPointGenerator.ts │ │ │ │ │ └── SliderPath.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mods/ │ │ │ │ │ ├── EasyMod.ts │ │ │ │ │ ├── HardRockMod.ts │ │ │ │ │ ├── HiddenMod.ts │ │ │ │ │ ├── Mods.spec.ts │ │ │ │ │ ├── Mods.ts │ │ │ │ │ └── StackingMod.ts │ │ │ │ ├── playfield.ts │ │ │ │ ├── replays/ │ │ │ │ │ ├── RawReplayData.ts │ │ │ │ │ ├── Replay.ts │ │ │ │ │ ├── ReplayClicks.ts │ │ │ │ │ ├── ReplayParser.spec.ts │ │ │ │ │ └── ReplayParser.ts │ │ │ │ └── utils/ │ │ │ │ ├── SortedList.spec.ts │ │ │ │ ├── SortedList.ts │ │ │ │ ├── index.spec.ts │ │ │ │ └── index.ts │ │ │ ├── test/ │ │ │ │ ├── PathApproximator.spec.ts │ │ │ │ └── utils/ │ │ │ │ └── asserts.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ ├── math/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── Vec2.ts │ │ │ │ ├── colors.ts │ │ │ │ ├── difficulty.spec.ts │ │ │ │ ├── difficulty.ts │ │ │ │ ├── easing.ts │ │ │ │ ├── float32.ts │ │ │ │ ├── index.ts │ │ │ │ ├── sliders.ts │ │ │ │ ├── time.spec.ts │ │ │ │ ├── time.ts │ │ │ │ └── utils.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ ├── pp/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── lib/ │ │ │ │ ├── diff.ts │ │ │ │ ├── mods.spec.ts │ │ │ │ ├── mods.ts │ │ │ │ ├── pp.ts │ │ │ │ ├── skills/ │ │ │ │ │ ├── aim.ts │ │ │ │ │ ├── flashlight.ts │ │ │ │ │ ├── speed.ts │ │ │ │ │ └── strain.ts │ │ │ │ ├── utils.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ └── skin/ │ │ ├── .babelrc │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── OsuSkinTextureConfig.ts │ │ │ ├── SkinConfig.ts │ │ │ ├── SkinConfigParser.spec.ts │ │ │ ├── SkinConfigParser.ts │ │ │ └── TextureTypes.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── osu-local/ │ │ ├── db-reader/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── src/ │ │ │ │ ├── DatabaseReader.ts │ │ │ │ ├── DatabaseTypes.ts │ │ │ │ ├── OsuBuffer.ts │ │ │ │ ├── OsuDBReader.ts │ │ │ │ ├── ScoresDBReader.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ ├── gosumemory/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── src/ │ │ │ │ ├── gosumemory.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ ├── osr-reader/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── src/ │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ ├── skin-reader/ │ │ │ ├── .babelrc │ │ │ ├── .eslintrc.json │ │ │ ├── README.md │ │ │ ├── jest.config.ts │ │ │ ├── src/ │ │ │ │ ├── SkinFolderReader.ts │ │ │ │ ├── SkinTextureResolver.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsconfig.lib.json │ │ │ └── tsconfig.spec.json │ │ └── utils/ │ │ ├── .babelrc │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── src/ │ │ │ ├── dates.ts │ │ │ ├── files.ts │ │ │ ├── index.ts │ │ │ ├── osuUserConfig.spec.ts │ │ │ ├── osuUserConfig.ts │ │ │ └── stable.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ └── osu-pixi/ │ ├── classic-components/ │ │ ├── .babelrc │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── src/ │ │ │ ├── DrawableSettings.ts │ │ │ ├── hitobjects/ │ │ │ │ ├── OsuClassicApproachCircle.ts │ │ │ │ ├── OsuClassicConstants.ts │ │ │ │ ├── OsuClassicCursor.ts │ │ │ │ ├── OsuClassicHitCircleArea.ts │ │ │ │ ├── OsuClassicJudgements.ts │ │ │ │ ├── OsuClassicSliderBall.ts │ │ │ │ ├── OsuClassicSliderBody.ts │ │ │ │ ├── OsuClassicSliderRepeat.ts │ │ │ │ ├── OsuClassicSliderTick.ts │ │ │ │ └── OsuClassicSpinner.ts │ │ │ ├── hud/ │ │ │ │ ├── HitErrorBar.ts │ │ │ │ ├── OsuClassicAccuracy.ts │ │ │ │ ├── OsuClassicJudgement.ts │ │ │ │ └── OsuClassicNumber.ts │ │ │ ├── index.ts │ │ │ ├── playfield/ │ │ │ │ └── PlayfieldBorder.ts │ │ │ ├── renderers/ │ │ │ │ └── BasicSliderTextureRenderer.ts │ │ │ └── utils/ │ │ │ ├── Animation.ts │ │ │ ├── Pixi.ts │ │ │ ├── Preparable.ts │ │ │ ├── Transformations.ts │ │ │ ├── constants.ts │ │ │ ├── numbers.spec.ts │ │ │ └── numbers.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ └── rewind/ │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.ts │ ├── src/ │ │ ├── index.ts │ │ └── lib/ │ │ └── AnalysisCursor.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.spec.json ├── migrations.json ├── nx.json ├── package.json ├── resources/ │ └── Skins/ │ ├── OsuDefaultSkin/ │ │ └── README.md │ └── RewindDefaultSkin/ │ ├── README.md │ └── skin.ini ├── testdata/ │ └── osu!/ │ ├── Replays/ │ │ ├── - Perfume - Daijobanai [Short kick slider] (2021-07-16) Perfect.osr │ │ ├── - Perfume - Daijobanai [Short kick slider] (2021-07-16) TooLateMissed.osr │ │ ├── - Perfume - Daijobanai [Slider (Repeat = 1)] (2021-07-07) Perfect.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) Osu Perfect.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) Osu SliderHeadMissedButTrackingWtf.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) Osu SliderHeadTooEarly.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) Osu SliderHeadTooLate.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) Perfect.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) SliderEndMissed.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) SliderHeadMissedButTrackingWtf.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) SliderHeadTooEarly.osr │ │ ├── - Perfume - Daijobanai [Slider 1] (2021-07-07) SliderHeadTooLate.osr │ │ ├── RyuK - HoneyWorks - Akatsuki Zukuyo [Taeyang_s Extra] (2019-06-08) Osu.osr │ │ ├── Varvalian - Aether Realm - The Sun, The Moon, The Star [Mourning Those Things I've Long Left Behind] (2019-05-15) Osu.osr │ │ ├── abstrakt - Gojou Mayumi - DANZEN! Futari wa PreCure Ver. MaxHeart (TV Size) [Insane] (2021-08-21) Osu.osr │ │ ├── abstrakt - PSYQUI - Hype feat. Such [Dreamer] (2021-08-08) Osu.osr │ │ ├── abstrakt - SHK - Violet Perfume [Insane] (2021-03-27) Osu.osr │ │ ├── abstrakt - Smile.dk - Koko Soko (AKIBA KOUBOU Eurobeat Remix) [Couch Mini2a] (2021-04-09) Osu.osr │ │ ├── abstrakt - sabi - true DJ MAG top ranker_s song Zenpen (katagiri Remix) [Senseabel's Extra] (2021-08-08) Osu.osr │ │ ├── hallowatcher - DECO27 - HIBANA feat. Hatsune Miku [Lock On] (2020-02-09) Osu.osr │ │ └── kellad - Asriel - Raison D'etre [EXist] (2025-02-16) Osu.osr │ ├── Songs/ │ │ ├── 1001507 ZUTOMAYO - Kan Saete Kuyashiiwa/ │ │ │ └── ZUTOMAYO - Kan Saete Kuyashiiwa (Nathan) [geragera].osu │ │ ├── 1010865 SHK - Violet Perfume [no video]/ │ │ │ └── SHK - Violet Perfume (ktgster) [Insane].osu │ │ ├── 1236927 Frums - XNOR XNOR XNOR/ │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [.-- .-. --- -. --. .-- .- -.--].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Beloved Exclusive].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Earth (atm)].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Earth].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Fire].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Metal].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Moon].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Sun].osu │ │ │ ├── Frums - XNOR XNOR XNOR (fanzhen0019) [Water].osu │ │ │ └── Frums - XNOR XNOR XNOR (fanzhen0019) [Wood].osu │ │ ├── 1302792 Smiledk - Koko Soko (AKIBA KOUBOU Eurobeat Remix)/ │ │ │ └── Smile.dk - Koko Soko (AKIBA KOUBOU Eurobeat Remix) ([ Couch ] Mini) [Couch Mini2a].osu │ │ ├── 1357624 sabi - true DJ MAG top ranker's song Zenpen (katagiri Remix)/ │ │ │ └── sabi - true DJ MAG top ranker's song Zenpen (katagiri Remix) (Nathan) [KEMOMIMI EDM SQUAD].osu │ │ ├── 1495211 Aether Realm - The Tower/ │ │ │ └── Aether Realm - The Tower (Takane) [Brick and Mortar].osu │ │ ├── 150945 Knife Party - Centipede/ │ │ │ └── Knife Party - Centipede (Sugoi-_-Desu) [This isn't a map, just a simple visualisation].osu │ │ ├── 158023 UNDEAD CORPORATION - Everything will freeze/ │ │ │ └── UNDEAD CORPORATION - Everything will freeze (Ekoro) [Time Freeze].osu │ │ ├── 29157 Within Temptation - The Unforgiving [no video]/ │ │ │ └── Within Temptation - The Unforgiving (Armin) [Marathon].osu │ │ ├── 351280 HoneyWorks - Akatsuki Zukuyo/ │ │ │ └── HoneyWorks - Akatsuki Zukuyo ([C u r i]) [Taeyang's Extra].osu │ │ ├── 607089 Xi - Rokujuu Nenme no Shinsoku Saiban _ Rapidity is a justice/ │ │ │ └── Xi - Rokujuu Nenme no Shinsoku Saiban ~ Rapidity is a justice (tokiko) [Extra Stage].osu │ │ ├── 863227 Brian The Sun - Lonely Go! (TV Size) [no video]/ │ │ │ └── Brian The Sun - Lonely Go! (TV Size) (Nevo) [Fiery's Extreme].osu │ │ ├── 931596 Apol - Hidamari no Uta/ │ │ │ └── Apol - Hidamari no Uta (-Keitaro) [Expert].osu │ │ ├── 933630 Aether Realm - The Sun, The Moon, The Star/ │ │ │ └── Aether Realm - The Sun, The Moon, The Star (ItsWinter) [Mourning Those Things I've Long Left Behind].osu │ │ └── 967347 Perfume - Daijobanai/ │ │ ├── Perfume - Daijobanai (eiri-) [Easy].osu │ │ ├── Perfume - Daijobanai (eiri-) [Hard].osu │ │ ├── Perfume - Daijobanai (eiri-) [HitCircle 1].osu │ │ ├── Perfume - Daijobanai (eiri-) [Normal].osu │ │ ├── Perfume - Daijobanai (eiri-) [Short kick slider].osu │ │ ├── Perfume - Daijobanai (eiri-) [Slider (Repeat = 1)].osu │ │ ├── Perfume - Daijobanai (eiri-) [Slider 1].osu │ │ └── Perfume - Daijobanai (eiri-) [Smile].osu │ └── osu!.me.cfg ├── tests/ │ ├── game-simulation/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── src/ │ │ │ ├── core/ │ │ │ │ ├── BeatmapBuilder.spec.ts │ │ │ │ ├── BlueprintParser.spec.ts │ │ │ │ ├── OsuStdReplayState.spec.ts │ │ │ │ ├── ReplayClicks.spec.ts │ │ │ │ ├── archive/ │ │ │ │ │ ├── reference/ │ │ │ │ │ │ ├── DANZEN.spec.ts │ │ │ │ │ │ └── KokoSokoMini.spec.ts │ │ │ │ │ └── replays/ │ │ │ │ │ ├── DaijobanaiSlider1.spec.ts │ │ │ │ │ ├── sunMoonStar.spec.ts │ │ │ │ │ └── topranker.spec.ts │ │ │ │ ├── bpm.test.ts │ │ │ │ └── hitobjects.test.ts │ │ │ ├── local/ │ │ │ │ ├── osudb.test.ts │ │ │ │ └── scoresdb.test.ts │ │ │ ├── others.ts │ │ │ ├── pp/ │ │ │ │ ├── diff.test.ts │ │ │ │ ├── ojsama.test.ts │ │ │ │ └── pp.test.ts │ │ │ ├── reference.ts │ │ │ └── util.ts │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ └── osu-stable-test-generator/ │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── src/ │ │ ├── app/ │ │ │ └── .gitkeep │ │ ├── assets/ │ │ │ └── .gitkeep │ │ ├── environments/ │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json ├── tools/ │ ├── generators/ │ │ └── .gitkeep │ └── tsconfig.tools.json ├── tsconfig.base.json └── workspace.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # Editor configuration, see http://editorconfig.org root = true [*] end_of_line = lf charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: .eslintrc.json ================================================ { "root": true, "ignorePatterns": ["**/*"], "plugins": ["@nrwl/nx"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { "@nrwl/nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, "allow": [], "depConstraints": [ { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } ] } ] } }, { "files": ["*.ts", "*.tsx"], "extends": ["plugin:@nrwl/nx/typescript"], "rules": {} }, { "files": ["*.js", "*.jsx"], "extends": ["plugin:@nrwl/nx/javascript"], "rules": {} } ] } ================================================ FILE: .gitattributes ================================================ # Often used LFS tracks # https://gist.github.com/ma-al/019f7f76498c55f0061120a5b13c6d88 # ------------------------------------------------ # Usual image types *.png filter=lfs diff=lfs merge=lfs -text *.jpeg filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text *.bmp filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text *.sketch filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text # ------------------------------------------------ # Audio files *.ogg filter=lfs diff=lfs merge=lfs -text *.mp3 filter=lfs diff=lfs merge=lfs -text *.wav filter=lfs diff=lfs merge=lfs -text # osu! related files *.osr filter=lfs diff=lfs merge=lfs -text *.db filter=lfs diff=lfs merge=lfs -text *.osu filter=lfs diff=lfs merge=lfs -text ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** (optional) Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Version [e.g. 22] **Additional context** **Please** also provide the following if they are mentioned and related: * Beatmap * Replay * Skin ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Describe the solution you'd like** A clear and concise description of what you want to happen. **Additional context** (optional) Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/build-release.yml ================================================ name: Build/release v2 on: push: workflow_dispatch: jobs: release: runs-on: ${{ matrix.os }} strategy: matrix: os: [ ubuntu-latest, windows-latest, macos-latest ] steps: - name: Checkout code uses: nschloe/action-cached-lfs-checkout@v1 - name: Install Node.js, NPM and Yarn uses: actions/setup-node@v3 with: node-version: 20 cache: 'npm' - name: Build/release Electron app uses: Yan-Jobs/action-electron-builder@v1.7.0 with: # GitHub token, automatically provided to the action # (No need to define this secret in the repo settings) github_token: ${{ secrets.github_token }} # If the commit is tagged with a version (e.g. "v1.0.0"), # release the app after building release: ${{ startsWith(github.ref, 'refs/tags/v') }} ================================================ FILE: .gitignore ================================================ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output /dist /tmp /out-tsc # dependencies /node_modules # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # misc /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log yarn-error.log testem.log /typings # System Files .DS_Store Thumbs.db # Custom /logs/ ================================================ FILE: .gitmodules ================================================ [submodule "testdata/osu-testdata"] path = testdata/osu-testdata url = https://github.com/abstrakt8/osu-testdata ================================================ FILE: .prettierignore ================================================ # Add files here to ignore them from prettier formatting /dist /coverage ================================================ FILE: .prettierrc ================================================ { "printWidth": 120, "tabWidth": 2, "trailingComma": "all", "endOfLine": "lf" } ================================================ FILE: .storybook/main.js ================================================ module.exports = { stories: [], addons: [ "@storybook/addon-links", "@storybook/addon-essentials", "storybook-css-modules-preset", { name: "@storybook/addon-postcss", options: { postcssLoaderOptions: { implementation: require("postcss"), }, }, }, ], }; ================================================ FILE: .storybook/preview.js ================================================ export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, } ================================================ FILE: .storybook/tsconfig.json ================================================ { "extends": "../tsconfig.base.json", "exclude": [ "../**/*.spec.js", "../**/*.spec.ts", "../**/*.spec.tsx", "../**/*.spec.jsx" ], "include": ["../**/*"] } ================================================ FILE: .storybook/webpack.config.js ================================================ /** * Export a function. Accept the base config as the only param. * @param {Object} options * @param {Required} options.config * @param {'DEVELOPMENT' | 'PRODUCTION'} options.mode - change the build configuration. 'PRODUCTION' is used when building the static version of storybook. */ module.exports = async ({ config, mode }) => { // Make whatever fine-grained changes you need // Return the altered config return config; }; ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "nrwl.angular-console", "esbenp.prettier-vscode", "firsttris.vscode-jest-runner", "dbaeumer.vscode-eslint" ] } ================================================ FILE: .yarnrc ================================================ # https://github.com/yarnpkg/yarn/issues/5540 # because of material-ui icons network-timeout 600000 ================================================ FILE: CONTRIBUTION.md ================================================ Project Structure === General --- This repository is a mono-repo that is currently focused on the development of the Rewind desktop application. However, as we are working with web technologies (WebGL, JS), there are also plans to implement a Rewind web version. This mono-repo is powered by the [nx](https://nx.dev/) build system. In `apps` you will find the applications - every folder corresponds to one "product". In `libs` you will find the common code that is shared across all the applications. In `tests` you will find the integration tests that will test the correctness of the libraries. Generally speaking, tests that have some external dependencies (such as a beatmap or replay file) will belong here. Unit tests should be written in the corresponding library `src` folder with `*.spec.ts` filename extension. > Familiarity with [Electron's process model](https://www.electronjs.org/docs/latest/tutorial/process-model) required. Setup === Basics --- Install the following: * Latest Node.js LTS version (e.g. `nvm install --lts`) * [`git-lfs`](https://git-lfs.github.com/) for the test data in `testdata/` When changes have been done to some submodules, you need to merge them as follows: ``` git submodule update --remote --merge ``` Building --- ```bash yarn install yarn run build ``` Developing --- First start the `desktop-frontend` to expose the frontend on port 4200 with "Hot Reloading" enabled. ```bash yarn run desktop-frontend:dev ``` Then start the Electron application: ```bash yarn run desktop-main:dev ``` If you make a change in the `desktop-main` package, you will need to rerun the command above again. Releasing --- When you want to create a new release, follow these steps: 1. Update the version in your project's `package.json` file (e.g. `1.2.3`) 2. Commit that change (`git commit -am v1.2.3`) 3. Tag your commit (`git tag v1.2.3`). Make sure your tag name's format is `v*.*.*`. Your workflow will use this tag to detect when to create a release 4. Push your changes to GitHub (`git push && git push --tags`) After building successfully, the action will publish your release artifacts. By default, a new release draft will be created on GitHub with download links for your app. If you want to change this behavior, have a look at the [`electron-builder` docs](https://www.electron.build). ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2021 abstrakt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Rewind

![Github releases](https://img.shields.io/github/v/release/abstrakt8/rewind?label=stable) ![Github releases](https://img.shields.io/github/v/release/abstrakt8/rewind?include_prereleases&label=latest) [![GitHub Releases Downloads](https://img.shields.io/github/downloads/abstrakt8/rewind/total?label=Downloads)](https://github.com/abstrakt8/rewind/releases/latest) [![Discord](https://img.shields.io/discord/841454370888351784.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/QubdHdnBVg) Rewind is a beatmap/replay analyzer for [osu!](https://osu.ppy.sh/) and is currently in development phase. BTMC on Road of Resistance
## Features This [video](https://www.youtube.com/watch?v=KDatdxvjdmc) shows most of the features listed down below: * Music playback * Speed change 0.25x-4.0x * Scrubbing (jumping to a specific time) * Toggling the "Hidden" mod * Analysis cursor * Slider dev mode * Hit error bar (slightly improved) * Statistics such as UR * Watching frame by frame * Skin support * Difficulty graph on the timeline * Gameplay events (Miss/SB/50/100) in the timeline * Support for unsubmitted maps * File watcher * Customizability of many elements (e.g. cursor size) * Shortcuts * ... ## Download The latest release for Windows/Linux can be found at the [release page](https://github.com/abstrakt8/rewind/releases). ## Questions / Feedback / Discussions If you have any questions about Rewind or want to contribute by submitting ideas, feel free to join the [osu! University Discord](https://discord.gg/QubdHdnBVg). It is an improvement-focused osu! hub, osu! coaching hub, and a platform for experienced players to spread their game knowledge to the public. ## Contribution (development, testing, documentation, ...) If you want to contribute, please join the [Dev Discord](https://discord.gg/pwCVATunVt). Currently, a large portion of the code is in a "volatile" / "prototype" / "proof of concept" state. So if you want to contribute by developing, please notify me beforehand. ================================================ FILE: apps/desktop/README.md ================================================ The Rewind desktop application consists of two renderers which are implemented in `backend` and `frontend`. The entry point of this application is implemented in "main.js". ================================================ FILE: apps/desktop/frontend/.babelrc ================================================ { "presets": [ [ "@nrwl/react/babel", { "runtime": "automatic" } ] ], "plugins": [] } ================================================ FILE: apps/desktop/frontend/.browserslistrc ================================================ # This file is used by: # 1. autoprefixer to adjust CSS to support the below specified browsers # 2. babel preset-env to adjust included polyfills # # For additional information regarding the format and rule options, please see: # https://github.com/browserslist/browserslist#queries # # If you need to support different browsers in production, you may tweak the list below. last 1 Chrome version last 1 Firefox version last 2 Edge major versions last 2 Safari major version last 2 iOS major versions Firefox ESR not IE 9-11 # For IE 9-11 support, remove 'not'. ================================================ FILE: apps/desktop/frontend/.eslintrc.json ================================================ { "extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, { "files": ["*.ts", "*.tsx"], "rules": {} }, { "files": ["*.js", "*.jsx"], "rules": {} } ] } ================================================ FILE: apps/desktop/frontend/.storybook/main.js ================================================ const rootMain = require("../../../../.storybook/main"); // Use the following syntax to add addons! // rootMain.addons.push(''); rootMain.stories.push(...["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"]); module.exports = rootMain; ================================================ FILE: apps/desktop/frontend/.storybook/preview.js ================================================ import React from "react"; import { addDecorator } from "@storybook/react"; import { ThemeProvider as EmotionThemeProvider } from "emotion-theming"; import { rewindTheme } from "../src/app/styles/theme"; import { CssBaseline, ThemeProvider } from "@mui/material"; const defaultTheme = rewindTheme; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, }; const withThemeProvider = (Story, context) => { return ( ); }; addDecorator(withThemeProvider); ================================================ FILE: apps/desktop/frontend/.storybook/tsconfig.json ================================================ { "extends": "../../../../libs/feature-replay-viewer/tsconfig.json", "compilerOptions": { "emitDecoratorMetadata": true, "outDir": "tsconfig" }, "files": [ "../../node_modules/@nrwl/react/typings/cssmodule.d.ts", "../../node_modules/@nrwl/react/typings/image.d.ts" ], "exclude": [ "../**/*.spec.ts", "../**/*.test.ts", "../**/*.spec.js", "../**/*.test.js", "../**/*.spec.tsx", "../**/*.test.tsx", "../**/*.spec.jsx", "../**/*.test.jsx" ], "include": [ "../src/**/*", "*.js" ] } ================================================ FILE: apps/desktop/frontend/.storybook/webpack.config.js ================================================ const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); const rootWebpackConfig = require("../../../../.storybook/webpack.config"); /** * Export a function. Accept the base config as the only param. * * @param {Parameters[0]} options */ module.exports = async ({ config, mode }) => { config = await rootWebpackConfig({ config, mode }); const tsPaths = new TsconfigPathsPlugin({ configFile: "./tsconfig.base.json", }); config.resolve.plugins ? config.resolve.plugins.push(tsPaths) : (config.resolve.plugins = [tsPaths]); // Found this here: https://github.com/nrwl/nx/issues/2859 // And copied the part of the solution that made it work const svgRuleIndex = config.module.rules.findIndex((rule) => { const { test } = rule; // @rewind: very important to have test?. return test?.toString().startsWith("/\\.(svg|ico"); }); config.module.rules[svgRuleIndex].test = /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/; config.module.rules.push( { test: /\.(png|jpe?g|gif|webp)$/, loader: require.resolve("url-loader"), options: { limit: 10000, // 10kB name: "[name].[hash:7].[ext]", }, }, { test: /\.svg$/, oneOf: [ // If coming from JS/TS file, then transform into React component using SVGR. { issuer: { test: /\.[jt]sx?$/, }, use: [ { loader: require.resolve("@svgr/webpack"), options: { svgo: false, titleProp: true, ref: true, }, }, { loader: require.resolve("url-loader"), options: { limit: 10000, // 10kB name: "[name].[hash:7].[ext]", esModule: false, }, }, ], }, // Fallback to plain URL loader. { use: [ { loader: require.resolve("url-loader"), options: { limit: 10000, // 10kB name: "[name].[hash:7].[ext]", }, }, ], }, ], }, ); return config; }; ================================================ FILE: apps/desktop/frontend/README.md ================================================ This is the frontend that gets deployed with the Electron app. That's why there is Some notes: - Uses `redux` for state management. - The `rewind` app is kind of a standalone application, therefore it's not really connected to the redux state management. ================================================ FILE: apps/desktop/frontend/jest.config.ts ================================================ /* eslint-disable */ export default { displayName: "frontend", preset: "../../../jest.preset.js", transform: { "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nrwl/react/plugins/jest", "^.+\\.[tj]sx?$": "babel-jest", }, moduleFileExtensions: ["ts", "tsx", "js", "jsx"], coverageDirectory: "../../../coverage/apps/frontend", }; ================================================ FILE: apps/desktop/frontend/proxy.conf.json ================================================ { "/api": { "target": "http://localhost:3333", "secure": false }, "/desktop-backend-api": { "target": "http://localhost:3333", "secure": false } } ================================================ FILE: apps/desktop/frontend/src/app/RewindApp.tsx ================================================ import { useAppDispatch } from "./hooks/redux"; import { Outlet, Routes, Route, useNavigate } from "react-router-dom"; import { LeftMenuSidebar } from "./components/sidebar/LeftMenuSidebar"; import { SplashScreen } from "./screens/splash/SplashScreen"; import { HomeScreen } from "./screens/home/HomeScreen"; import { Box, Divider, Stack } from "@mui/material"; import { UpdateModal } from "./components/update/UpdateModal"; import { useEffect } from "react"; import { downloadFinished, downloadProgressed, newVersionAvailable } from "./store/update/slice"; import { frontendAPI } from "./api"; import { ipcRenderer } from "electron"; import { useTheaterContext } from "./providers/TheaterProvider"; import { ELECTRON_UPDATE_FLAG } from "./utils/constants"; import { Analyzer } from "./screens/analyzer/Analyzer"; import { SetupScreen } from "./screens/setup/SetupScreen"; function NormalView() { return ( ); } export function RewindApp() { const navigate = useNavigate(); const dispatch = useAppDispatch(); const theater = useTheaterContext(); useEffect(() => { void theater.common.initialize(); // For now, we will just navigate to the analyzer app since we only have one tool ipcRenderer.on("onManualReplayOpen", (event, file) => { navigate("/app/analyzer"); void theater.analyzer.loadReplay(file); }); (async function () { if (!(await theater.analyzer.osuFolderService.hasValidOsuFolderSet())) { console.log("osu! folder was not set, redirecting to the setup screen."); navigate("/setup"); } else { console.log(`osu! folder = ${theater.analyzer.osuFolderService.getOsuFolder()}`); console.log(`osu!/Songs folder = ${theater.analyzer.osuFolderService.songsFolder$.getValue()}`); console.log(`osu!/Replays folder = ${theater.analyzer.osuFolderService.replaysFolder$.getValue()}`); navigate("/app/analyzer"); } })(); if (ELECTRON_UPDATE_FLAG) { frontendAPI.onUpdateAvailable((version) => { dispatch(newVersionAvailable(version)); }); frontendAPI.onDownloadFinished(() => { dispatch(downloadFinished()); }); frontendAPI.onUpdateDownloadProgress((updateInfo) => { const { total, bytesPerSecond, transferred } = updateInfo; dispatch(downloadProgressed({ downloadedBytes: transferred, totalBytes: total, bytesPerSecond })); }); // We start checking for update on the front end, otherwise if we start it from the Electron main process, the // notification might get lost (probably need a message queue if we want to start from the Electron main process) frontendAPI.checkForUpdate(); } }, []); return ( } /> } /> }> } /> } /> ); } ================================================ FILE: apps/desktop/frontend/src/app/api.ts ================================================ import { ipcRenderer } from "electron"; type Listener = (...args: any) => any; // Maybe use it for onUpdateDownloadProgress interface UpdateInfo { total: number; transferred: number; bytesPerSecond: number; } export const frontendAPI = { getPath: (type: string) => ipcRenderer.invoke("getPath", type), selectDirectory: (defaultPath: string) => ipcRenderer.invoke("selectDirectory", defaultPath), selectFile: (defaultPath: string) => ipcRenderer.invoke("selectFile", defaultPath), reboot: () => ipcRenderer.invoke("reboot"), getAppVersion: () => ipcRenderer.invoke("getAppVersion"), getPlatform: () => ipcRenderer.invoke("getPlatform"), onManualReplayOpen: (listener: Listener) => ipcRenderer.on("onManualReplayOpen", (event, file) => listener(file)), onUpdateAvailable: (listener: Listener) => ipcRenderer.on("onUpdateAvailable", (event, version) => listener(version)), onUpdateDownloadProgress: (listener: Listener) => ipcRenderer.on("onUpdateDownloadProgress", (event, info) => listener(info)), startDownloadingUpdate: () => ipcRenderer.invoke("startDownloadingUpdate"), onDownloadFinished: (listener: Listener) => ipcRenderer.on("onDownloadFinished", (event) => listener()), checkForUpdate: () => ipcRenderer.invoke("checkForUpdate"), quitAndInstall: () => ipcRenderer.invoke("quitAndInstall"), }; ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseAudioSettingsPanel.tsx ================================================ import { Box, Slider, Stack, Tooltip, Typography } from "@mui/material"; import { VolumeDown, VolumeUp } from "@mui/icons-material"; type Change = (x: number) => void; interface BaseAudioSettingsPanelProps { master: number; effects: number; music: number; onMasterChange: Change; onEffectsChange: Change; onMusicChange: Change; onMutedChange: (muted: boolean) => unknown; } export function VolumeSlider({ disabled, onChange, value }: { disabled?: boolean; onChange: Change; value: number }) { return ( onChange((x as number) / 100)} /> ); } export function BaseAudioSettingsPanel(props: BaseAudioSettingsPanelProps) { const { master, effects, music, onMasterChange, onEffectsChange, onMusicChange, onMutedChange } = props; return ( Master Volume Music Volume Effects Volume {/*https://mui.com/components/tooltips/#disabled-elements*/} ); } ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseCurrentTime.stories.tsx ================================================ import { Meta, Story } from "@storybook/react"; import { Paper } from "@mui/material"; import { BaseCurrentTime, GameCurrentTimeProps } from "./BaseCurrentTime"; export default { component: BaseCurrentTime, title: "BaseCurrentTime", argTypes: { onClick: { action: "onClick executed!" }, }, } as Meta; const Template: Story = (args) => ( ); export const Time200ms = Template.bind({}); Time200ms.args = { currentTimeInMs: 200, }; export const Time1727ms = Template.bind({}); Time1727ms.args = { currentTimeInMs: 1727, }; export const TimeHour = Template.bind({}); TimeHour.args = { currentTimeInMs: 60 * 60 * 1000 + 727, }; ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseCurrentTime.tsx ================================================ import React, { forwardRef, ForwardRefRenderFunction, useImperativeHandle, useRef } from "react"; import { formatGameTime } from "@osujs/math"; import { darken, Typography } from "@mui/material"; export type GameCurrentTimeProps = Record; export interface GameCurrentTimeHandle { updateTime: (timeInMs: number) => void; } const ForwardCurrentTime: ForwardRefRenderFunction = (props, ref) => { const refMain = useRef(null); const refMs = useRef(null); useImperativeHandle(ref, () => ({ updateTime(timeInMs) { const [timeHMS, timeMS] = formatGameTime(timeInMs, true).split("."); if (refMain.current) refMain.current.textContent = timeHMS; if (refMs.current) refMs.current.textContent = "." + timeMS; }, })); return ( 0:00 darken(theme.palette.text.primary, 0.6) }} ref={refMs}> .000 ); }; export const BaseCurrentTime = forwardRef(ForwardCurrentTime); ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseDialog.tsx ================================================ import { IconButton, Link, Typography } from "@mui/material"; import { FaDiscord } from "react-icons/fa"; import React from "react"; import { RewindLinks } from "../../utils/constants"; import { useCommonManagers } from "../../providers/TheaterProvider"; export function PromotionFooter() { const appVersion = useCommonManagers().appInfoService.version; return ( Rewind {appVersion} by{" "} abstrakt {" "} | osu! University ); } ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseGameTimeSlider.stories.tsx ================================================ import { Box } from "@mui/material"; import { Meta, Story } from "@storybook/react"; import { BaseGameTimeSlider, BaseGameTimeSliderProps } from "./BaseGameTimeSlider"; export default { component: BaseGameTimeSlider, title: "BaseGameTimeSlider", argTypes: { onClick: { action: "onClick executed!" }, }, } as Meta; const Template: Story = (args) => ( ); export const Primary = Template.bind({}); Primary.args = { backgroundEnable: true, }; export const NotEnabled = Template.bind({}); NotEnabled.args = { backgroundEnable: false, }; ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseGameTimeSlider.tsx ================================================ import { Box, Slider, styled } from "@mui/material"; import { formatGameTime, rgbToInt } from "@osujs/math"; import { ignoreFocus } from "../../utils/focus"; import { useEffect, useRef } from "react"; import { Renderer, Sprite, Texture } from "pixi.js"; import { AdjustmentFilter } from "@pixi/filter-adjustment"; import { Container } from "@pixi/display"; import { Chart, registerables } from "chart.js"; import colorString from "color-string"; // Chart.register(...registerables); interface EventLineProps { color: string; tooltip: string; positions: number[]; } type EventType = { timings: number[]; tooltip: string; color: string }; export interface BaseGameTimeSliderProps { backgroundEnable?: boolean; // Duration in ms duration: number; // Time in ms currentTime: number; // When the slider is dragged onChange: (value: number) => any; events: EventType[]; difficulties: number[]; } function drawPlaybarEvents(canvas: HTMLCanvasElement, eventTypes: EventType[], duration: number) { const renderer = new Renderer({ view: canvas, backgroundAlpha: 0.0 }); const stage = new Container(); const { height, width } = renderer.screen; const eventLineHeight = height / eventTypes.length; for (let i = 0; i < eventTypes.length; i++) { const eventType = eventTypes[i]; const container = new Container(); const descriptor = colorString.get(eventType.color); if (!descriptor) break; const tint = rgbToInt(descriptor.value); // console.log(`Event ${i} has color ${tint} and ${descriptor.value}`); for (const timing of eventType.timings) { const sprite = Sprite.from(Texture.WHITE); sprite.tint = tint; // TODO: This needs to be centered -> but doesn't really matter since it's kinda negligible sprite.width = 1; sprite.height = eventLineHeight; sprite.position.set((timing / duration) * width, 0); container.addChild(sprite); } stage.addChild(container); container.position.set(0, i * eventLineHeight); } stage.filters = [new AdjustmentFilter({ brightness: 0.7 })]; stage.interactive = false; stage.interactiveChildren = false; renderer.render(stage); // Doesn't need ticker right? } const PlaybarEventsCanvas = styled("canvas")` //background: aqua; position: absolute; height: 40px; width: 100%; top: 0; left: 0; transform: translate(0, -50%); `; const DifficultyCanvas = styled("canvas")` //position: absolute; //top: 0; //left: 0; //transform: translate(0, -100%); //background: aqua; `; function drawDifficulty(canvas: HTMLCanvasElement, data: number[]) { // const labels = [0, 20, 40, 50, 99, 1000]; const labels = data.map((_) => ""); const chart = new Chart(canvas, { type: "line", options: { maintainAspectRatio: false, // To hide the little "knobs" elements: { point: { radius: 0, }, }, scales: { x: { display: false, }, y: { display: false, }, }, plugins: { legend: { display: false, }, tooltip: { enabled: false, }, }, }, data: { labels, datasets: [ { data, fill: true, // borderColor: "rgba(255, 255, 255, 0.5)", // borderColor: "hsla(0,4%,31%,0.77)", backgroundColor: "hsla(0, 2%, 44%, 0.3)", tension: 0.5, }, ], }, }); chart.draw(); return chart; } export function BaseGameTimeSlider(props: BaseGameTimeSliderProps) { const { backgroundEnable, duration, currentTime, onChange, events, difficulties } = props; const valueLabelFormat = (value: number) => formatGameTime(value); const eventsCanvas = useRef(null!); const difficultyCanvas = useRef(null!); useEffect(() => { drawPlaybarEvents(eventsCanvas.current, events, duration); }, [eventsCanvas, events, duration]); useEffect(() => { const chart = drawDifficulty(difficultyCanvas.current, difficulties); return () => { chart.destroy(); }; }, [difficultyCanvas, difficulties]); return ( onChange(x as number)} getAriaValueText={valueLabelFormat} valueLabelFormat={valueLabelFormat} valueLabelDisplay={"auto"} step={16} max={duration} /> ); } ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseSettingsModal.stories.tsx ================================================ import { Meta, Story } from "@storybook/react"; import { BaseSettingsModal, SettingsProps } from "./BaseSettingsModal"; import { Paper } from "@mui/material"; export default { component: BaseSettingsModal, title: "BaseSettingsModal", argTypes: { onClose: { action: "onClick executed!" }, }, } as Meta; const Template: Story = (args) => ( ); export const Primary = Template.bind({}); Primary.args = { tabs: [ { component:
General
, label: "General" }, { component:
Skinning
, label: "Cool" }, ], }; ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/BaseSettingsModal.tsx ================================================ import { Box, Divider, IconButton, Paper, Slider, Stack, Tab, Tabs, Typography } from "@mui/material"; import React from "react"; import { Close, Settings as SettingsIcon, Visibility, VisibilityOff } from "@mui/icons-material"; import { PromotionFooter } from "./BaseDialog"; interface SettingTab { component: React.ReactNode; label: string; } export interface SettingsProps { onClose?: () => void; tabs: Array; opacity: number; onOpacityChange: (o: number) => unknown; tabIndex: number; onTabIndexChange: (i: number) => unknown; } const MIN_OPACITY = 25; const MAX_OPACITY = 100; export function BaseSettingsModal(props: SettingsProps) { const { onClose, tabs, opacity, onOpacityChange, tabIndex, onTabIndexChange } = props; const handleTabChange = (event: any, newValue: any) => { onTabIndexChange(newValue); }; const displayedTab = tabs[tabIndex].component; return ( Settings {/*TODO: Holy moly, the CSS here needs to be changed a bit*/} {tabs.map(({ label }, index) => ( ))} {/*TODO: For example this should not (?) be hardcoded */} {displayedTab} onOpacityChange(MIN_OPACITY)}> onOpacityChange(v as number)} step={5} min={MIN_OPACITY} max={MAX_OPACITY} valueLabelFormat={(value: number) => `${value}%`} sx={{ width: "12em" }} valueLabelDisplay={"auto"} /> onOpacityChange(MAX_OPACITY)}> ); } ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/GameCanvas.tsx ================================================ import React, { useEffect, useRef } from "react"; import { useAnalysisApp } from "../../providers/TheaterProvider"; import { Box, CircularProgress, IconButton, Stack, Tooltip, Typography } from "@mui/material"; import { useObservable } from "rxjs-hooks"; import { LightningBoltIcon } from "@heroicons/react/solid"; import InfoIcon from "@mui/icons-material/Info"; import { ignoreFocus } from "../../utils/focus"; import CloseIcon from "@mui/icons-material/Close"; function EmptyState() { return ( No replay loaded In osu! press F2 while being at a score/fail screen to load the replay You can also load a replay with the menu action "File > Open Replay (Ctrl+O)" ); } export const GameCanvas = () => { const canvas = useRef(null); const containerRef = useRef(null); const analysisApp = useAnalysisApp(); const { status } = useObservable(() => analysisApp.scenarioManager.scenario$, { status: "DONE" }); const showOverlay = status !== "DONE"; useEffect(() => { if (containerRef.current) { containerRef.current.append(analysisApp.stats()); } }, [analysisApp]); useEffect(() => { if (status === "INIT") { analysisApp.stats().hidden = true; } else { analysisApp.stats().hidden = false; } }, [status, analysisApp]); useEffect(() => { if (canvas.current) { console.log("Initializing renderer to the canvas"); analysisApp.onEnter(canvas.current); } return () => analysisApp.onHide(); }, [analysisApp]); return ( {status === "DONE" && ( analysisApp.scenarioManager.clearReplay()} onFocus={ignoreFocus} > )} {/*Overlay*/} {showOverlay && ( {status === "INIT" && } {status === "LOADING" && } {status === "ERROR" && Something wrong happened...} )} ); }; ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/HelpModal.stories.tsx ================================================ import { Meta, Story } from "@storybook/react"; import { HelpBox } from "./HelpModal"; export default { component: HelpBox, title: "HelpModal", argTypes: { onClose: { action: "onClose executed!" }, }, } as Meta; const Template: Story = (args) => {}} />; export const Primary = Template.bind({}); ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/HelpModal.tsx ================================================ import React from "react"; import styled from "styled-components"; import { Box, Divider, IconButton, Modal, Paper, Stack, Typography } from "@mui/material"; import { Close, Help } from "@mui/icons-material"; import { PromotionFooter } from "./BaseDialog"; export function Key({ text }: { text: string }) { return ( {text} // // {text} // ); } const ShortcutBox = styled.div` display: grid; grid-template-columns: max-content 1fr; grid-column-gap: 1em; grid-row-gap: 0.5em; align-items: center; justify-content: center; `; const leftArrowKey = "←"; const rightArrowKey = "→"; const upArrowKey = "↑"; const downArrowKey = "↓"; const leftKeys = [leftArrowKey, "a"]; const rightKeys = [rightArrowKey, "d"]; function KeyBindings({ separator = "+", keys, inline }: { separator?: string; keys: string[]; inline?: boolean }) { return (
{keys.map((k, i) => ( {i + 1 < keys.length && ` ${separator} `} ))}
); } function Title({ children }: any) { return {children}; } // Which one? const orSeparator = " or "; // const orSeparator = " , "; function PlaybarNavigationShortcuts() { return ( Shortcuts Start / Pause Increase speed Decrease speed Small jump back Small jump forward {/*
*/} {/* + */} {/*
*/} Micro jump back {/*
*/} {/* + */} {/*
*/} Micro jump forward {/*
*/} {/* + */} {/*
*/} Large jump back {/*
*/} {/* + */} {/*
*/} Large jump forward Toggle hidden
); } interface HelpModalProps { isOpen: boolean; onClose: () => void; } export function HelpBox(props: Pick) { const { onClose } = props; return ( {/*MenuBar could be reused*/} Help {/**/} {/*Footer*/} ); } export function HelpModalDialog(props: HelpModalProps) { const { isOpen, onClose } = props; return ( ); } ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/PlayBar.tsx ================================================ import { Box, Button, Divider, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Popover, Stack, Tooltip, Typography, } from "@mui/material"; import { Help, MoreVert, PauseCircle, PhotoCamera, PlayCircle, Settings, VolumeOff, VolumeUp, } from "@mui/icons-material"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BaseAudioSettingsPanel } from "./BaseAudioSettingsPanel"; import { BaseGameTimeSlider } from "./BaseGameTimeSlider"; import { useGameClockControls, useGameClockTime } from "../../hooks/game-clock"; import { formatGameTime } from "@osujs/math"; import { useAudioSettings, useAudioSettingsService } from "../../hooks/audio"; import { useModControls } from "../../hooks/mods"; import modHiddenImg from "../../../assets/mod_hidden.png"; import { ALLOWED_SPEEDS, PlaybarColors } from "../../utils/constants"; import { useSettingsModalContext } from "../../providers/SettingsProvider"; import { ReplayAnalysisEvent } from "@osujs/core"; import { useObservable } from "rxjs-hooks"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; import { HelpModalDialog } from "./HelpModal"; import { BaseCurrentTime, GameCurrentTimeHandle } from "./BaseCurrentTime"; import { ignoreFocus } from "../../utils/focus"; import { useAnalysisApp, useCommonManagers } from "../../providers/TheaterProvider"; import { DEFAULT_PLAY_BAR_SETTINGS } from "../../services/common/playbar"; const centerUp = { anchorOrigin: { vertical: "top", horizontal: "center", }, transformOrigin: { vertical: "bottom", horizontal: "center", }, }; function MoreMenu() { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const handleClick = (event: any) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const analyzer = useAnalysisApp(); const handleTakeScreenshot = () => { analyzer.screenshotTaker.takeScreenshot(); handleClose(); }; const [helpOpen, setHelpOpen] = useState(false); const handleOpenHelp = () => { setHelpOpen(true); handleClose(); }; return ( <> setHelpOpen(false)} /> Take Screenshot Help ); } function AudioButton() { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const handlePopOverOpen = (event: any) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const { volume, muted } = useAudioSettings(); const service = useAudioSettingsService(); const handleClick = () => { service.toggleMuted(); }; return ( <> {muted ? : } service.setMuted(x)} onMasterChange={(x) => service.setMasterVolume(x)} onMusicChange={(x) => service.setMusicVolume(x)} onEffectsChange={(x) => service.setEffectsVolume(x)} /> ); } // Connected function PlayButton() { const { isPlaying, toggleClock } = useGameClockControls(); const Icon = !isPlaying ? PlayCircle : PauseCircle; return ( ); } // https://css-tricks.com/using-requestanimationframe-with-react-hooks/ const timeAnimateFPS = 30; function CurrentTime() { const analyzer = useAnalysisApp(); const requestRef = useRef(0); const timeRef = useRef(null); // const animate = () => {}; useEffect(() => { // requestRef.current = requestAnimationFrame(animate); let last = -1; const requiredElapsed = 1000 / timeAnimateFPS; function animate(currentTimestamp: number) { const elapsed = currentTimestamp - last; if (elapsed > requiredElapsed) { if (timeRef.current) timeRef.current.updateTime(analyzer.gameClock.timeElapsedInMs); last = currentTimestamp; } requestRef.current = requestAnimationFrame(animate); } requestRef.current = requestAnimationFrame(animate); return () => { if (requestRef.current) cancelAnimationFrame(requestRef.current); }; }, [analyzer]); return ( // We MUST fix the width because the font is not monospace e.g. "111" is thinner than "000" // Also if the duration is more than an hour there will also be a slight shift ); } function groupTimings(events: ReplayAnalysisEvent[]) { const missTimings: number[] = []; const mehTimings: number[] = []; const okTimings: number[] = []; const sliderBreakTimings: number[] = []; events.forEach((e) => { switch (e.type) { case "HitObjectJudgement": // TODO: for lazer style, this needs some rework if (e.isSliderHead) { if (e.verdict === "MISS") sliderBreakTimings.push(e.time); return; } else { if (e.verdict === "MISS") missTimings.push(e.time); if (e.verdict === "MEH") mehTimings.push(e.time); if (e.verdict === "OK") okTimings.push(e.time); } // if(e.verdict === "GREAT" && show300s) events.push(); // Not sure if this will ever be implemented break; case "CheckpointJudgement": if (!e.hit && !e.isLastTick) sliderBreakTimings.push(e.time); break; case "UnnecessaryClick": // TODO break; } }); return { missTimings, mehTimings, okTimings, sliderBreakTimings }; } function GameTimeSlider() { // TODO: Depending on if replay is loaded and settings const backgroundEnable = true; const currentTime = useGameClockTime(15); const { seekTo, duration } = useGameClockControls(); const { gameSimulator } = useAnalysisApp(); const { playbarSettingsStore } = useCommonManagers(); const replayEvents = useObservable(() => gameSimulator.replayEvents$, []); const difficulties = useObservable(() => gameSimulator.difficulties$, []); const playbarSettings = useObservable(() => playbarSettingsStore.settings$, DEFAULT_PLAY_BAR_SETTINGS); const events = useMemo(() => { const { sliderBreakTimings, missTimings, mehTimings, okTimings } = groupTimings(replayEvents); return [ { color: PlaybarColors.MISS, timings: missTimings, tooltip: "Misses" }, { color: PlaybarColors.SLIDER_BREAK, timings: sliderBreakTimings, tooltip: "Sliderbreaks" }, { color: PlaybarColors.MEH, timings: mehTimings, tooltip: "50s" }, { color: PlaybarColors.OK, timings: okTimings, tooltip: "100s" }, ]; }, [replayEvents]); return ( ); } function Duration() { const { duration } = useGameClockControls(); const f = formatGameTime(duration); return {f}; } function HiddenButton() { const { setHidden, hidden: hiddenEnabled } = useModControls(); const handleClick = useCallback(() => setHidden(!hiddenEnabled), [hiddenEnabled, setHidden]); return ( ); } function SettingsButton() { const { onSettingsModalOpenChange } = useSettingsModalContext(); return ( onSettingsModalOpenChange(true)} onFocus={ignoreFocus}> ); } interface BaseSpeedButtonProps { value: number; onChange: (value: number) => any; } const speedLabels: Record = { 0.75: "HT", 1.5: "DT" } as const; function BaseSpeedButton(props: BaseSpeedButtonProps) { const { value, onChange } = props; const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const handleClick = (event: any) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const formatSpeed = (s: number) => `${s}x`; // Floating point issues? return ( <> {ALLOWED_SPEEDS.map((s) => ( { onChange(s); handleClose(); }} sx={{ width: "120px", maxWidth: "100%" }} > {formatSpeed(s)} {speedLabels[s] ?? ""} ))} ); } function SpeedButton() { const { speed, setSpeed } = useGameClockControls(); return ( // // ); } function RecordButton() { // TODO: Probably stop at a certain time otherwise the program might crash due to memory issue const { clipRecorder } = useAnalysisApp(); const recordingSince = useObservable(() => clipRecorder.recordingSince$, 0); const isRecording = recordingSince > 0; const recordingTime = "3:00"; const handleClick = useCallback(() => { if (isRecording) { clipRecorder.stopRecording(); } else { clipRecorder.startRecording(); } }, [isRecording, clipRecorder]); return ( ); } const VerticalDivider = () => ; export function PlayBar() { return ( {/**/} ); } ================================================ FILE: apps/desktop/frontend/src/app/components/analyzer/SettingsModal.tsx ================================================ import { useSettingsModalContext } from "../../providers/SettingsProvider"; import { Autocomplete, Box, Button, FormControlLabel, FormGroup, Modal, Paper, Slider, Stack, Switch, TextField, Typography, } from "@mui/material"; import { BaseSettingsModal } from "./BaseSettingsModal"; import { useCommonManagers } from "../../providers/TheaterProvider"; import { useCallback, useEffect, useMemo } from "react"; import { useObservable } from "rxjs-hooks"; import { DEFAULT_HIT_ERROR_BAR_SETTINGS } from "../../services/common/hit-error-bar"; import { DEFAULT_PLAY_BAR_SETTINGS } from "../../services/common/playbar"; import { DEFAULT_OSU_SKIN_ID, DEFAULT_REWIND_SKIN_ID, SkinId, SkinSource, stringToSkinId } from "../../model/SkinId"; import { DEFAULT_BEATMAP_RENDER_SETTINGS } from "../../services/common/beatmap-render"; import { DEFAULT_SKIN_SETTINGS } from "../../services/common/skin"; import { DEFAULT_REPLAY_CURSOR_SETTINGS } from "../../services/common/replay-cursor"; import { DEFAULT_ANALYSIS_CURSOR_SETTINGS } from "../../services/analysis/analysis-cursor"; import { frontendAPI } from "../../api"; const sourceName: Record = { osu: "osu!/Skins Folder", rewind: "Rewind", }; const formatToPercent = (value: number) => `${value}%`; function BeatmapBackgroundSettings() { const theater = useCommonManagers(); const { beatmapBackgroundSettingsStore } = theater; const settings = useObservable(() => beatmapBackgroundSettingsStore.settings$, { blur: 0, enabled: false, dim: 0 }); return ( Beatmap Background Blur beatmapBackgroundSettingsStore.setBlur((v as number) / 100)} valueLabelDisplay={"auto"} valueLabelFormat={formatToPercent} /> Dim beatmapBackgroundSettingsStore.setDim((v as number) / 100)} valueLabelDisplay={"auto"} valueLabelFormat={formatToPercent} /> ); } function BeatmapRenderSettings() { const { beatmapRenderSettingsStore } = useCommonManagers(); const settings = useObservable(() => beatmapRenderSettingsStore.settings$, DEFAULT_BEATMAP_RENDER_SETTINGS); return ( beatmapRenderSettingsStore.setSliderDevMode(event.target.checked)} /> } label={"Slider Dev Mode"} /> {/* draw slider ends*/} ); } function AnalysisCursorSettingsSection() { const { analysisCursorSettingsStore } = useCommonManagers(); const settings = useObservable(() => analysisCursorSettingsStore.settings$, DEFAULT_ANALYSIS_CURSOR_SETTINGS); return ( Analysis Cursor analysisCursorSettingsStore.setEnabled(event.target.checked)} /> } label={"Enabled"} /> ); } function ReplayCursorSettingsSection() { const { replayCursorSettingsStore } = useCommonManagers(); const settings = useObservable(() => replayCursorSettingsStore.settings$, DEFAULT_REPLAY_CURSOR_SETTINGS); return ( Replay Cursor replayCursorSettingsStore.changeSettings((s) => (s.enabled = event.target.checked)) } /> } label={"Enabled"} /> replayCursorSettingsStore.changeSettings((s) => (s.smoothCursorTrail = event.target.checked)) } /> } label={"Smooth Cursor Trail"} /> Scale replayCursorSettingsStore.changeSettings((s) => (s.scale = (v as number) / 100))} valueLabelDisplay={"auto"} /> ); } function HitErrorBarSettingsSection() { const { hitErrorBarSettingsStore } = useCommonManagers(); const settings = useObservable(() => hitErrorBarSettingsStore.settings$, DEFAULT_HIT_ERROR_BAR_SETTINGS); return ( {/*TODO: Enabled*/} Hit Error Bar Scaling hitErrorBarSettingsStore.changeSettings((s) => (s.scale = (v as number) / 100))} valueLabelDisplay={"auto"} /> ); } function PlaybarSettingsSection() { const { playbarSettingsStore } = useCommonManagers(); const settings = useObservable(() => playbarSettingsStore.settings$, DEFAULT_PLAY_BAR_SETTINGS); return ( Playbar playbarSettingsStore.changeSettings((s) => (s.difficultyGraphEnabled = event.target.checked)) } /> } label={"Show difficulty graph"} /> ); } function ResetAllSettingsSection() { const resetSettings = useCallback(() => { localStorage.clear(); frontendAPI.reboot(); }, []); return ( ); } function OtherSettings() { return ( ); } function GameplaySettings() { return ( ); } function SkinsSettings() { // TODO: Button for synchronizing skin list again const theater = useCommonManagers(); const { preferredSkinId } = useObservable(() => theater.skinSettingsStore.settings$, DEFAULT_SKIN_SETTINGS); const chosenSkinId = stringToSkinId(preferredSkinId); const skins = useObservable(() => theater.skinManager.skinList$, []); const skinOptions: SkinId[] = useMemo( () => [DEFAULT_OSU_SKIN_ID, DEFAULT_REWIND_SKIN_ID].concat(skins.map((name) => ({ source: "osu", name }))), [skins], ); useEffect(() => { theater.skinManager.loadSkinList(); }, [theater]); // TODO: const handleSkinChange = useCallback( (skinId: SkinId) => { (async function () { try { await theater.skinManager.loadSkin(skinId); } catch (e) { // Show some error dialog console.error(`Could not load skin ${skinId}`); } })(); // TODO: Error handling }, [theater], ); return ( sourceName[option.source]} value={chosenSkinId} onChange={(event, newValue) => { if (newValue) { handleSkinChange(newValue as SkinId); } }} getOptionLabel={(option: SkinId) => option.name} sx={{ width: 300 }} renderInput={(params) => } isOptionEqualToValue={(option, value) => option.name === value.name && option.source === value.source} /> ); } export function SettingsModal() { const { onSettingsModalOpenChange, settingsModalOpen, opacity, onTabIndexChange, onOpacityChange, tabIndex } = useSettingsModalContext(); const onClose = () => onSettingsModalOpenChange(false); return ( }, { label: "Skins", component: , }, { label: "Other", component: }, ]} /> ); } ================================================ FILE: apps/desktop/frontend/src/app/components/logo/RewindLogo.tsx ================================================ import { Stack, Typography } from "@mui/material"; import { FastRewind } from "@mui/icons-material"; export const RewindLogo = () => ( REWIND ); ================================================ FILE: apps/desktop/frontend/src/app/components/sidebar/LeftMenuSidebar.tsx ================================================ import { RewindLogo } from "../logo/RewindLogo"; import { Badge, Box, Divider, IconButton, Stack, Tooltip } from "@mui/material"; import { Home } from "@mui/icons-material"; import { FaMicroscope } from "react-icons/fa"; import React, { useCallback, useEffect, useState } from "react"; import { useAppDispatch, useAppSelector } from "../../hooks/redux"; import UpdateIcon from "@mui/icons-material/Update"; import { setUpdateModalOpen } from "../../store/update/slice"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { ELECTRON_UPDATE_FLAG } from "../../utils/constants"; import { useAppInfo } from "../../hooks/app-info"; const tooltipPosition = { anchorOrigin: { vertical: "center", horizontal: "right", }, transformOrigin: { vertical: "center", horizontal: "left", }, }; const repoOwner = "abstrakt8"; const repoName = "rewind"; const latestReleaseUrl = `https://github.com/${repoOwner}/${repoName}/releases/latest`; const latestReleaseApi = `https://api.github.com/repos/${repoOwner}/${repoName}/releases/latest`; function useCheckForUpdate() { const { appVersion } = useAppInfo(); const { newVersion } = useAppSelector((state) => state.updater); const [state, setState] = useState<{ hasNewUpdate: boolean; latestVersion: string }>({ hasNewUpdate: false, latestVersion: "", }); useEffect(() => { (async function () { const response = await fetch(latestReleaseApi); const json = await response.json(); // Should be something like "v0.1.0" const tagName = json["tag_name"] as string; if (!tagName) { return; } // Removes the "v" prefix const latestVersion = tagName.substring(1); const hasNewUpdate = appVersion !== latestVersion; setState({ hasNewUpdate, latestVersion }); console.log( `Current release: ${appVersion} and latest release: ${latestVersion}, therefore hasNewUpdate=${hasNewUpdate}`, ); })(); }, [appVersion]); if (ELECTRON_UPDATE_FLAG) { return { hasNewUpdate: newVersion !== appVersion, latestVersion: newVersion }; } else { return state; } } export function LeftMenuSidebar() { const dispatch = useAppDispatch(); const location = useLocation(); const navigate = useNavigate(); const state = useCheckForUpdate(); const openUpdateModal = () => dispatch(setUpdateModalOpen(true)); const onNewUpdateAvailableButtonClick = useCallback(() => { if (ELECTRON_UPDATE_FLAG) { openUpdateModal(); } else { window.open(latestReleaseUrl); } }, []); const handleLinkClick = (to: string) => () => navigate(to); const buttonColor = (name: string) => (location.pathname.endsWith(name) ? "primary" : "default"); return ( theme.spacing(10), paddingBottom: 2, }} gap={1} p={1} alignItems={"center"} component={"nav"} > {/*Nothing*/} ); } ================================================ FILE: apps/desktop/frontend/src/app/components/update/UpdateModal.tsx ================================================ import { Box, Button, Divider, IconButton, LinearProgress, Link, Modal, Paper, Stack, Typography } from "@mui/material"; import { useAppDispatch, useAppSelector } from "../../hooks/redux"; import { setUpdateModalOpen } from "../../store/update/slice"; import { Close } from "@mui/icons-material"; import React from "react"; import { frontendAPI } from "../../api"; import { useAppInfo } from "../../hooks/app-info"; const units = ["bytes", "KB", "MB", "GB", "TB", "PB"]; function niceBytes(x: any) { let l = 0, n = parseInt(x, 10) || 0; while (n >= 1024 && ++l) { n = n / 1024; } return n.toFixed(n < 10 && l > 0 ? 1 : 0) + " " + units[l]; } function versionUrl(version: string) { const repoOwner = "abstrakt8"; const repoName = "rewind"; // The version does not contain "v" return `https://github.com/${repoOwner}/${repoName}/releases/v${version}`; } export function UpdateModal() { const { modalOpen, newVersion, isDownloading, downloadedBytes, bytesPerSecond, downloadFinished, error, totalBytes } = useAppSelector((state) => state.updater); const dispatch = useAppDispatch(); const handleClose = () => dispatch(setUpdateModalOpen(false)); const { appVersion } = useAppInfo(); const updateAvailable = newVersion !== null; const title = updateAvailable ? `Update available` : `Up to date`; const downloadProgress = (downloadedBytes / totalBytes) * 100; return ( {title} {!updateAvailable && You are on the latest version: {appVersion}} {updateAvailable && ( New Rewind version available:{" "} {newVersion} {isDownloading && ( {downloadFinished ? "Finished downloading!" : "Downloading..."}{" "} {`${niceBytes(downloadedBytes)} / ${niceBytes(totalBytes)} (${downloadProgress.toFixed(2)} %)`} )} {isDownloading && } {downloadFinished && ( Installation will happen when you close the application. )} {!downloadFinished && ( )} {downloadFinished && ( )} )} ); } ================================================ FILE: apps/desktop/frontend/src/app/hooks/app-info.ts ================================================ import { useCommonManagers } from "../providers/TheaterProvider"; export function useAppInfo() { const { appInfoService } = useCommonManagers(); return { appVersion: appInfoService.version, platform: appInfoService.platform, }; } ================================================ FILE: apps/desktop/frontend/src/app/hooks/audio.ts ================================================ import { useCommonManagers } from "../providers/TheaterProvider"; import { useObservable } from "rxjs-hooks"; import { AudioSettings } from "../services/common/audio/settings"; export function useAudioSettingsService() { const theater = useCommonManagers(); return theater.audioSettingsService; } const defaultSettings: AudioSettings = { volume: { effects: 0, master: 0, music: 0 }, muted: true }; export function useAudioSettings() { const audioSettingsService = useAudioSettingsService(); return useObservable(() => audioSettingsService.settings$, defaultSettings); } ================================================ FILE: apps/desktop/frontend/src/app/hooks/energy-saver.ts ================================================ // import { useStageContext } from "../components/StageProvider/StageProvider"; // export function useEnergySaver(enabled = true) { // const isVisible = usePageVisibility(); // // const { stage } = useStageContext(); // const { pauseClock } = useGameClockContext(); // // useEffect(() => { // if (!enabled) { // return; // } // if (!isVisible) { // pauseClock(); // stage.stopTicker(); // } else { // stage.startTicker(); // } // }, [isVisible, stage, pauseClock, enabled]); // } ================================================ FILE: apps/desktop/frontend/src/app/hooks/game-clock.ts ================================================ import { useCallback, useState } from "react"; import { useObservable } from "rxjs-hooks"; import { useAnalysisApp } from "../providers/TheaterProvider"; import { ALLOWED_SPEEDS } from "../utils/constants"; import { useInterval } from "./interval"; export function useGameClock() { const analyzer = useAnalysisApp(); return analyzer.gameClock; } // TODO: FLOATING POINT EQUALITY ALERT const speedIndex = (speed: number) => ALLOWED_SPEEDS.indexOf(speed); const nextSpeed = (speed: number) => ALLOWED_SPEEDS[Math.min(ALLOWED_SPEEDS.length - 1, speedIndex(speed) + 1)]; const prevSpeed = (speed: number) => ALLOWED_SPEEDS[Math.max(0, speedIndex(speed) - 1)]; export function useGameClockControls() { const clock = useGameClock(); const isPlaying = useObservable(() => clock.isPlaying$, false); const duration = useObservable(() => clock.durationInMs$, 0); const speed = useObservable(() => clock.speed$, 1.0); const toggleClock = useCallback(() => clock.toggle(), [clock]); const seekTo = useCallback((timeInMs: number) => clock.seekTo(timeInMs), [clock]); const setSpeed = useCallback((x: number) => clock.setSpeed(x), [clock]); const increaseSpeed = useCallback(() => clock.setSpeed(nextSpeed(clock.speed)), [clock]); const decreaseSpeed = useCallback(() => clock.setSpeed(prevSpeed(clock.speed)), [clock]); const seekForward = useCallback((timeInMs: number) => clock.seekTo(clock.timeElapsedInMs + timeInMs), [clock]); const seekBackward = useCallback((timeInMs: number) => clock.seekTo(clock.timeElapsedInMs - timeInMs), [clock]); return { isPlaying, duration, speed, // Actions setSpeed, increaseSpeed, decreaseSpeed, toggleClock, seekTo, seekForward, seekBackward, }; } // 60FPS by default export function useGameClockTime(fps = 60) { const gameClock = useGameClock(); const [time, setTime] = useState(0); useInterval(() => { setTime(gameClock.timeElapsedInMs); }, 1000 / fps); return time; } ================================================ FILE: apps/desktop/frontend/src/app/hooks/interval.ts ================================================ import { useEffect, useRef } from "react"; export function useInterval(callback: () => void, delay: number | null) { const savedCallback = useRef(callback); // Remember the latest callback if it changes. useEffect(() => { savedCallback.current = callback; }, [callback]); // Set up the interval. useEffect(() => { // Don't schedule if no delay is specified. if (delay === null) { // eslint-disable-next-line @typescript-eslint/no-empty-function return () => {}; } const id = setInterval(() => savedCallback.current(), delay); return () => clearInterval(id); }, [delay]); } ================================================ FILE: apps/desktop/frontend/src/app/hooks/mods.ts ================================================ import { useAnalysisApp } from "../providers/TheaterProvider"; import { useObservable } from "rxjs-hooks"; import { useCallback } from "react"; export function useModControls() { const { modSettingsService } = useAnalysisApp(); const modSettings = useObservable(() => modSettingsService.modSettings$, { flashlight: false, hidden: false }); const setHidden = useCallback((value: boolean) => modSettingsService.setHidden(value), [modSettingsService]); return { ...modSettings, setHidden }; } ================================================ FILE: apps/desktop/frontend/src/app/hooks/redux.ts ================================================ import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { AppDispatch, RootState } from "../store"; export const useAppDispatch = () => useDispatch(); // Export a hook that can be reused to resolve types export const useAppSelector: TypedUseSelectorHook = useSelector; ================================================ FILE: apps/desktop/frontend/src/app/hooks/shortcuts.ts ================================================ import { useHotkeys } from "react-hotkeys-hook"; import { useGameClockControls } from "./game-clock"; import { useModControls } from "./mods"; // TODO: Configurable // These should stay constant or make them dynamic depending on gameClock speed const microscopeJump = 1; const frameJump = 16; // Assuming 16fps const mediumJump = 1 * 1000; const largeJump = 15 * 1000; const leftKeys = ["a", "left"]; const rightKeys = ["d", "right"]; const upKeys = ["w", "up"]; const downKeys = ["s", "down"]; const generateKeyComboSimple = (keys: string[]) => keys.join(", "); const generateKeyCombo = (modifier = "", keys: string[]) => keys.map((k) => `${modifier}+${k}`).join(", "); // TODO: Get platform from AppInfo, so that we can also support MacOS (currently they are hardcoded for Windows/Linux) export function useShortcuts() { const { toggleClock, seekBackward, seekForward, increaseSpeed, decreaseSpeed } = useGameClockControls(); const { setHidden, hidden } = useModControls(); useHotkeys(generateKeyComboSimple(upKeys), () => increaseSpeed(), [increaseSpeed]); useHotkeys(generateKeyComboSimple(downKeys), () => decreaseSpeed(), [decreaseSpeed]); useHotkeys("space", () => toggleClock(), [toggleClock]); useHotkeys(generateKeyCombo("shift", leftKeys), () => seekBackward(mediumJump), [seekBackward]); useHotkeys(generateKeyCombo("shift", rightKeys), () => seekForward(mediumJump), [seekForward]); useHotkeys(generateKeyCombo("ctrl", leftKeys), () => seekBackward(microscopeJump), [seekBackward]); useHotkeys(generateKeyCombo("ctrl", rightKeys), () => seekForward(microscopeJump), [seekForward]); useHotkeys(generateKeyComboSimple(leftKeys), () => seekBackward(frameJump), [seekBackward]); useHotkeys(generateKeyComboSimple(rightKeys), () => seekForward(frameJump), [seekForward]); // These have really bad collisions // useHotkeys(`alt+${leftKey}`, () => seekBackward(frameJump), [seekBackward]); // useHotkeys(`alt+${rightKey}`, () => seekForward(frameJump), [seekForward]); // useHotkeys(`ctrl+${leftKey}`, () => seekBackward(largeJump), [seekBackward]); // useHotkeys(`ctrl+${rightKey}`, () => seekForward(largeJump), [seekForward]); useHotkeys("f", () => setHidden(!hidden), [hidden, setHidden]); } ================================================ FILE: apps/desktop/frontend/src/app/model/BlueprintInfo.ts ================================================ export interface BlueprintInfo { md5Hash: string; lastPlayed: Date; title: string; artist: string; creator: string; // This is assuming that they are in the folderName folderName: string; audioFileName: string; osuFileName: string; // [Events] bgFileName?: string; // Usually unknown unless .osu file is parsed } ================================================ FILE: apps/desktop/frontend/src/app/model/OsuReplay.ts ================================================ // TODO: Rename this to replay or something import { OsuClassicMod, ReplayFrame } from "@osujs/core"; export type OsuReplay = { md5hash: string; beatmapMd5: string; gameVersion: number; mods: OsuClassicMod[]; player: string; // Could be useful to draw frames: ReplayFrame[]; }; ================================================ FILE: apps/desktop/frontend/src/app/model/Skin.ts ================================================ import { rgbToInt } from "@osujs/math"; import { Texture } from "@pixi/core"; import { comboDigitFonts, defaultDigitFonts, generateDefaultSkinConfig, hitCircleDigitFonts, OsuSkinTextures, SkinConfig, } from "@rewind/osu/skin"; export type SkinTexturesByKey = Partial>; // Read // https://github.com/pixijs/pixi.js/blob/dev/packages/loaders/src/TextureLoader.ts export interface ISkin { config: SkinConfig; getComboColorForIndex(i: number): number; getTexture(key: OsuSkinTextures): Texture; getTextures(key: OsuSkinTextures): Texture[]; getHitCircleNumberTextures(): Texture[]; getComboNumberTextures(): Texture[]; } export class EmptySkin implements ISkin { config = generateDefaultSkinConfig(false); getComboColorForIndex(): number { return 0; } getComboNumberTextures(): [] { return []; } getHitCircleNumberTextures(): Texture[] { return []; } getTexture() { return Texture.EMPTY; } getTextures(): Texture[] { return [Texture.EMPTY]; } } /** * A simple skin that can provide the basic information a beatmap needs. */ export class Skin implements ISkin { static EMPTY = new Skin(generateDefaultSkinConfig(false), {}); constructor(public readonly config: SkinConfig, public readonly textures: SkinTexturesByKey) {} getComboColorForIndex(i: number): number { const comboColors = this.config.colors.comboColors; return rgbToInt(comboColors[i % comboColors.length]); } getTexture(key: OsuSkinTextures): Texture { return this.getTextures(key)[0]; } getTextures(key: OsuSkinTextures): Texture[] { if (!(key in this.textures)) { return [Texture.EMPTY]; // throw new Error("Texture key not found"); } const list = this.textures[key] as Texture[]; if (list.length === 0) { return [Texture.EMPTY]; } else { return list; } } getHitCircleNumberTextures(): Texture[] { return hitCircleDigitFonts.map((h) => this.getTexture(h)); } getComboNumberTextures(): Texture[] { return comboDigitFonts.map((h) => this.getTexture(h)); } // The textures that are used for every other numbers on the interface (except combo) getScoreTextures(): Texture[] { return defaultDigitFonts.map((h) => this.getTexture(h)); } } ================================================ FILE: apps/desktop/frontend/src/app/model/SkinId.ts ================================================ export type SkinSource = "rewind" | "osu"; export interface SkinId { source: SkinSource; name: string; } export function skinIdToString({ source, name }: SkinId) { return `${source}:${name}`; } const isSkinSource = (s: string): s is SkinSource => s === "rewind" || s === "osu"; export function stringToSkinId(str: string): SkinId { const [source, name] = str.split(":"); if (isSkinSource(source)) { return { source, name }; } else { throw Error("Skin source wrong"); } } export const DEFAULT_OSU_SKIN_ID: SkinId = { source: "rewind", name: "OsuDefaultSkin" }; export const DEFAULT_REWIND_SKIN_ID: SkinId = { source: "rewind", name: "RewindDefaultSkin" }; ================================================ FILE: apps/desktop/frontend/src/app/providers/SettingsProvider.tsx ================================================ import React, { createContext, useContext, useState } from "react"; type ISettingsContext = { settingsModalOpen: boolean; onSettingsModalOpenChange: (open: boolean) => unknown; opacity: number; onOpacityChange: (opacity: number) => unknown; tabIndex: number; onTabIndexChange: (index: number) => unknown; }; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion export const SettingsContext = createContext(null!); interface SettingsModalProps { children: React.ReactNode; defaultOpen?: boolean; } const DEFAULT_OPACITY = 100; // maxOpacity = 100% export function SettingsModalProvider({ children, defaultOpen = false }: SettingsModalProps) { const [settingsModalOpen, setSettingsModalOpen] = useState(defaultOpen); const [opacity, onOpacityChange] = useState(DEFAULT_OPACITY); const [tabIndex, onTabIndexChange] = useState(0); return ( setSettingsModalOpen(b), opacity, onOpacityChange, tabIndex, onTabIndexChange, }} > {children} ); } export function useSettingsModalContext() { const context = useContext(SettingsContext); if (!context) { throw Error("useSettingsModalContext can only be used within a SettingsModalProvider"); } return context; } ================================================ FILE: apps/desktop/frontend/src/app/providers/TheaterProvider.tsx ================================================ import React, { createContext, useContext } from "react"; import { RewindTheater } from "../services/common/CommonManagers"; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion export const TheaterContext = createContext(null!); interface TheaterProviderProps { theater: RewindTheater; children: React.ReactNode; } export function TheaterProvider({ theater, children }: TheaterProviderProps) { return {children}; } export function useTheaterContext() { const context = useContext(TheaterContext); if (!context) { throw Error("useTheaterContext can only be used within a TheaterProvider"); } return context; } export function useCommonManagers() { const theater = useTheaterContext(); return theater.common; } export function useAnalysisApp() { const theater = useTheaterContext(); return theater.analyzer; } ================================================ FILE: apps/desktop/frontend/src/app/screens/analyzer/Analyzer.tsx ================================================ import { Paper, Stack } from "@mui/material"; import { PlayBar } from "../../components/analyzer/PlayBar"; import { useShortcuts } from "../../hooks/shortcuts"; import { GameCanvas } from "../../components/analyzer/GameCanvas"; import { SettingsModal } from "../../components/analyzer/SettingsModal"; import { SettingsModalProvider } from "../../providers/SettingsProvider"; import { useEffect } from "react"; import { environment } from "../../../environments/environment"; import { useAnalysisApp } from "../../providers/TheaterProvider"; export function Analyzer() { const analyzer = useAnalysisApp(); // Shortcuts will then only be available when this page is is open useShortcuts(); useEffect(() => { void analyzer.initialize(); if (environment.debugAnalyzer) { void analyzer.loadReplay(environment.debugAnalyzer.replayPath); } }, [analyzer]); return ( ); } ================================================ FILE: apps/desktop/frontend/src/app/screens/home/HomeScreen.tsx ================================================ import React from "react"; import { FaDiscord, FaTwitter, FaYoutube } from "react-icons/fa"; import { IconButton, Link, Stack, Typography } from "@mui/material"; import { FastRewind } from "@mui/icons-material"; import { useAppInfo } from "../../hooks/app-info"; import { discordUrl, RewindLinks, twitterUrl, youtubeUrl } from "../../utils/constants"; // This page is actually just a placeholder for an overview page that can show things such as "Recently Played", etc. export function HomeScreen() { const { appVersion } = useAppInfo(); return ( REWIND Rewind {appVersion} by{" "} abstrakt osu! University ); } ================================================ FILE: apps/desktop/frontend/src/app/screens/setup/SetupScreen.tsx ================================================ import * as React from "react"; import { useCallback, useEffect, useState } from "react"; import { Alert, Badge, Box, Button, IconButton, InputBase, Paper, Stack } from "@mui/material"; import { RewindLogo } from "../../components/logo/RewindLogo"; import { Help, RocketLaunch } from "@mui/icons-material"; import FolderIcon from "@mui/icons-material/Folder"; import { frontendAPI } from "../../api"; import { useNavigate } from "react-router-dom"; import { useAnalysisApp } from "../../providers/TheaterProvider"; interface DirectorySelectionProps { value: string | null; onChange: (value: string | null) => void; placeHolder: string; badgeOnEmpty?: boolean; } function DirectorySelection({ value, onChange, placeHolder, badgeOnEmpty }: DirectorySelectionProps) { const handleSelectFolderClick = useCallback(() => { frontendAPI.selectDirectory(value ?? "").then((path) => { if (path !== null) { onChange(path); } }); }, [onChange, value]); const onInputChange = useCallback( (event: any) => { onChange(event.target.value); }, [onChange], ); const invisibleBadge = !badgeOnEmpty || !!value; return ( {/*{value ?? placeHolder}*/} ); } const setupWikiUrl = "https://github.com/abstrakt8/rewind/wiki/Setup"; // TODO: Maybe tell which file is actually missing export function SetupScreen() { // TODO: Add a guess for directory path const [directoryPath, setDirectoryPath] = useState(null); const [saveEnabled, setSaveEnabled] = useState(false); const navigate = useNavigate(); const analyzer = useAnalysisApp(); // const [updateOsuDirectory, updateState] = useUpdateOsuDirectoryMutation(); const [showErrorMessage, setShowErrorMessage] = useState(false); const handleConfirmClick = useCallback(async () => { if (!directoryPath) { return; } const isValid = await analyzer.osuFolderService.isValidOsuFolder(directoryPath); if (isValid) { analyzer.osuFolderService.setOsuFolder(directoryPath); navigate("/app/analyzer"); } else { setShowErrorMessage(true); } }, [navigate, analyzer.osuFolderService, directoryPath]); const handleOnDirectoryChange = useCallback( (path: string | null) => { setDirectoryPath(path); // TODO: Just directly validate since it's so fast setShowErrorMessage(false); }, [setShowErrorMessage], ); // Makes sure that the button is only clickable when it's allowed. useEffect(() => { setSaveEnabled(directoryPath !== null); }, [directoryPath]); return ( {showErrorMessage && ( <>
Does not look a valid osu! directory!
)}
); } ================================================ FILE: apps/desktop/frontend/src/app/screens/splash/SplashScreen.tsx ================================================ import { HashLoader } from "react-spinners"; import { Stack } from "@mui/material"; export function SplashScreen() { return (
Services are getting ready ...
); } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/AnalysisApp.ts ================================================ import { ReplayService } from "../common/local/ReplayService"; import { GameplayClock } from "../common/game/GameplayClock"; import { BeatmapManager } from "../manager/BeatmapManager"; import { GameSimulator } from "../common/game/GameSimulator"; import { injectable } from "inversify"; import { PixiRendererManager } from "../renderers/PixiRendererManager"; import { AnalysisSceneManager } from "../manager/AnalysisSceneManager"; import { GameLoop } from "../common/game/GameLoop"; import { ScenarioManager } from "../manager/ScenarioManager"; import { ReplayFileWatcher } from "../common/local/ReplayFileWatcher"; import { OsuFolderService } from "../common/local/OsuFolderService"; import { ClipRecorder } from "../manager/ClipRecorder"; import { ModSettingsService } from "./mod-settings"; import { ScreenshotTaker } from "./screenshot"; @injectable() export class AnalysisApp { constructor( public readonly gameClock: GameplayClock, public readonly gameSimulator: GameSimulator, public readonly scenarioManager: ScenarioManager, public readonly modSettingsService: ModSettingsService, public readonly replayWatcher: ReplayFileWatcher, public readonly screenshotTaker: ScreenshotTaker, public readonly clipRecorder: ClipRecorder, public readonly osuFolderService: OsuFolderService, private readonly replayService: ReplayService, private readonly gameLoop: GameLoop, private readonly beatmapManager: BeatmapManager, private readonly sceneManager: AnalysisSceneManager, private readonly pixiRenderer: PixiRendererManager, ) {} stats() { return this.gameLoop.stats(); } initialize() { console.log("AnalysisApp: Initialize"); this.replayWatcher.startWatching(); this.scenarioManager.initialize(); } close() {} onEnter(canvas: HTMLCanvasElement) { this.pixiRenderer.initializeRenderer(canvas); // this.gameLoop.startTicker(); } onHide() { this.gameClock.pause(); this.pixiRenderer.destroy(); // this.gameLoop.stopTicker(); } /** * Loads the replay and the corresponding beatmap and makes the application ready to visualize the replay. * * Note: This procedure can be optimized in the future, but for now it's ok. * * @param replayId the id of the replay to load */ async loadReplay(replayId: string) { return this.scenarioManager.loadReplay(replayId); } } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/analysis-cursor.ts ================================================ import { PersistentService } from "../core/service"; import { injectable } from "inversify"; import { JSONSchemaType } from "ajv"; import { CursorSettings } from "../common/cursor"; export interface AnalysisCursorSettings extends CursorSettings { colorKey1: number; colorKey2: number; colorNoKeys: number; colorBothKeys: number; } export const DEFAULT_ANALYSIS_CURSOR_SETTINGS: AnalysisCursorSettings = Object.freeze({ scale: 0.8, enabled: true, scaleWithCS: true, colorNoKeys: 0x5d6463, // gray colorKey1: 0xffa500, // orange) colorKey2: 0x00ff00, // green) colorBothKeys: 0x3cbdc1, // cyan) }); export const AnalysisCursorSettingsSchema: JSONSchemaType = { type: "object", properties: { scale: { type: "number", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.scale }, enabled: { type: "boolean", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.enabled }, scaleWithCS: { type: "boolean", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.scaleWithCS }, colorNoKeys: { type: "number", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.colorNoKeys }, colorKey1: { type: "number", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.colorKey1 }, colorKey2: { type: "number", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.colorKey2 }, colorBothKeys: { type: "number", default: DEFAULT_ANALYSIS_CURSOR_SETTINGS.colorBothKeys }, }, required: [], }; @injectable() export class AnalysisCursorSettingsStore extends PersistentService { key = "analysis-cursor"; schema: JSONSchemaType = AnalysisCursorSettingsSchema; getDefaultValue(): AnalysisCursorSettings { return DEFAULT_ANALYSIS_CURSOR_SETTINGS; } setEnabled(enabled: boolean) { this.changeSettings((s) => (s.enabled = enabled)); } } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/createRewindAnalysisApp.ts ================================================ import { Container } from "inversify"; import { AnalysisApp } from "./AnalysisApp"; import { PixiRendererManager } from "../renderers/PixiRendererManager"; import { GameplayClock } from "../common/game/GameplayClock"; import { EventEmitter2 } from "eventemitter2"; import { BeatmapManager } from "../manager/BeatmapManager"; import { ReplayManager } from "../manager/ReplayManager"; import { GameSimulator } from "../common/game/GameSimulator"; import { AnalysisSceneManager } from "../manager/AnalysisSceneManager"; import { SceneManager } from "../common/scenes/SceneManager"; import { GameLoop } from "../common/game/GameLoop"; import { AnalysisScene } from "./scenes/AnalysisScene"; import { BeatmapBackgroundFactory } from "../renderers/components/background/BeatmapBackground"; import { TextureManager } from "../textures/TextureManager"; import { AnalysisStage } from "../renderers/components/stage/AnalysisStage"; import { ForegroundHUDPreparer } from "../renderers/components/hud/ForegroundHUDPreparer"; import { PlayfieldFactory } from "../renderers/components/playfield/PlayfieldFactory"; import { PlayfieldBorderFactory } from "../renderers/components/playfield/PlayfieldBorderFactory"; import { HitObjectsContainerFactory } from "../renderers/components/playfield/HitObjectsContainerFactory"; import { HitCircleFactory } from "../renderers/components/playfield/HitCircleFactory"; import { SliderFactory } from "../renderers/components/playfield/SliderFactory"; import { SpinnerFactory } from "../renderers/components/playfield/SpinnerFactory"; import { SliderTextureManager } from "../renderers/components/sliders/SliderTextureManager"; import { CursorPreparer } from "../renderers/components/playfield/CursorPreparer"; import { JudgementPreparer } from "../renderers/components/playfield/JudgementPreparer"; import { AudioEngine } from "../common/audio/AudioEngine"; import { ScenarioManager } from "../manager/ScenarioManager"; import { ReplayFileWatcher } from "../common/local/ReplayFileWatcher"; import { ClipRecorder } from "../manager/ClipRecorder"; import { IdleScene } from "./scenes/IdleScene"; import { KeyPressWithNoteSheetPreparer } from "../renderers/components/keypresses/KeyPressOverlay"; import { ModSettingsService } from "./mod-settings"; import { ScreenshotTaker } from "./screenshot"; import { STAGE_TYPES } from "../types"; /** * This is a Rewind specific constructor of the "Analysis" tool (not to be used outside of Rewind). * * Reason is that many "Rewind" tools share the same services in order to provide smoother experiences. * * Example: If I use the "Cutter" tool then I want to use the same preferred skin that is used across Rewind. * * The analysis tool can be used as a standalone app though. */ export function createRewindAnalysisApp(commonContainer: Container) { const container = new Container({ defaultScope: "Singleton" }); container.parent = commonContainer; container.bind(STAGE_TYPES.EVENT_EMITTER).toConstantValue(new EventEmitter2()); container.bind(ReplayManager).toSelf(); container.bind(BeatmapManager).toSelf(); container.bind(GameplayClock).toSelf(); container.bind(ScenarioManager).toSelf(); container.bind(ModSettingsService).toSelf(); container.bind(GameSimulator).toSelf(); container.bind(PixiRendererManager).toSelf(); // Plugins ? container.bind(ScreenshotTaker).toSelf(); container.bind(ClipRecorder).toSelf(); container.bind(ReplayFileWatcher).toSelf(); // Assets container.bind(TextureManager).toSelf(); container.bind(AudioEngine).toSelf(); // Scenes container.bind(AnalysisSceneManager).toSelf(); container.bind(SceneManager).toSelf(); // Skin is given by above // container.bind(SkinManager).toSelf(); // AnalysisScenes container.bind(AnalysisScene).toSelf(); container.bind(IdleScene).toSelf(); // Sliders container.bind(SliderTextureManager).toSelf(); container.bind(AnalysisStage).toSelf(); { container.bind(BeatmapBackgroundFactory).toSelf(); container.bind(ForegroundHUDPreparer).toSelf(); container.bind(KeyPressWithNoteSheetPreparer).toSelf(); container.bind(PlayfieldFactory).toSelf(); { container.bind(PlayfieldBorderFactory).toSelf(); container.bind(HitObjectsContainerFactory).toSelf(); container.bind(HitCircleFactory).toSelf(); container.bind(SliderFactory).toSelf(); container.bind(SpinnerFactory).toSelf(); container.bind(CursorPreparer).toSelf(); container.bind(JudgementPreparer).toSelf(); } } container.bind(GameLoop).toSelf(); container.bind(AnalysisApp).toSelf(); return container.get(AnalysisApp); } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/mod-settings.ts ================================================ import { injectable } from "inversify"; import { BehaviorSubject } from "rxjs"; interface AnalysisModSettings { hidden: boolean; flashlight: boolean; } @injectable() export class ModSettingsService { modSettings$: BehaviorSubject; constructor() { this.modSettings$ = new BehaviorSubject({ flashlight: false, hidden: false, }); } get modSettings() { return this.modSettings$.getValue(); } setHidden(hidden: boolean) { this.modSettings$.next({ ...this.modSettings, hidden }); } setFlashlight(flashlight: boolean) { this.modSettings$.next({ ...this.modSettings, flashlight }); } } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/scenes/AnalysisScene.ts ================================================ import { UserScene } from "../../common/scenes/IScene"; import { injectable } from "inversify"; import { AnalysisStage } from "../../renderers/components/stage/AnalysisStage"; import { GameplayClock } from "../../common/game/GameplayClock"; import { GameSimulator } from "../../common/game/GameSimulator"; // Just the normal analysis scene that updates according to a virtual game clock. @injectable() export class AnalysisScene implements UserScene { constructor( private readonly gameClock: GameplayClock, private readonly gameSimulator: GameSimulator, private readonly analysisStage: AnalysisStage, ) {} update() { this.gameClock.tick(); this.gameSimulator.simulate(this.gameClock.timeElapsedInMs); this.analysisStage.updateAnalysisStage(); } get stage() { return this.analysisStage.stage; } destroy(): void { this.analysisStage.destroy(); } init(data: unknown): void { // Do nothing } preload(): Promise { return Promise.resolve(undefined); } create(): void { // Do nothing } } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/scenes/IdleScene.ts ================================================ // Usually if there is no replay loaded // Just show a text // In the future -> show a cool looking logo import { UserScene } from "../../common/scenes/IScene"; import { Container } from "pixi.js"; import { injectable } from "inversify"; @injectable() export class IdleScene implements UserScene { stage = new Container(); destroy(): void { this.stage.destroy(); } init(data: string): void {} async preload(): Promise { // Do nothing } update(): void { // Do nothing } create(): void { this.stage = new Container(); // const text = new Text("Load a beatmap/replay to get started!", { // fontSize: 16, // fill: 0xeeeeee, // fontFamily: "Arial", // align: "left", // }); // // Maybe center it somewhere // this.stage.addChild(text); } } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/scenes/LoadingScene.ts ================================================ // Very simple loading scene that shows the progress of the loading import { UserScene } from "../../common/scenes/IScene"; import { Container, Text } from "pixi.js"; export class LoadingScene implements UserScene { stage: Container = new Container(); destroy(): void {} init(): void { // } async preload(): Promise { // } update(): void { // } create(): void { this.stage = new Container(); const text = new Text("LoadingScene...", { fontSize: 16, fill: 0xeeeeee, fontFamily: "Arial", align: "left", }); // Maybe center it somewhere this.stage.addChild(text); } } ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/scenes/ResultsScreenScene.ts ================================================ // Should be shown after the replay or can be manually triggered ================================================ FILE: apps/desktop/frontend/src/app/services/analysis/screenshot.ts ================================================ import { injectable } from "inversify"; import { AnalysisScene } from "./scenes/AnalysisScene"; import { PixiRendererManager } from "../renderers/PixiRendererManager"; @injectable() export class ScreenshotTaker { constructor(private readonly analysisScene: AnalysisScene, private readonly pixiRenderer: PixiRendererManager) { } takeScreenshot() { const renderer = this.pixiRenderer.getRenderer(); if (!renderer) return; const canvas: HTMLCanvasElement = renderer.plugins.extract.canvas(this.analysisScene.stage); canvas.toBlob( (blob) => { const a = document.createElement("a"); a.download = `Rewind Screenshot ${new Date().toISOString()}.jpg`; a.href = URL.createObjectURL(blob!); a.click(); a.remove(); }, "image/jpeg", 0.9, ); } } ================================================ FILE: apps/desktop/frontend/src/app/services/common/CommonManagers.ts ================================================ import { Container, injectable } from "inversify"; import { ReplayService } from "./local/ReplayService"; import { SkinLoader } from "./local/SkinLoader"; import { AudioService } from "./audio/AudioService"; import { createRewindAnalysisApp } from "../analysis/createRewindAnalysisApp"; import { AudioSettingsStore } from "./audio/settings"; import { BeatmapBackgroundSettingsStore } from "./beatmap-background"; import { PlayfieldBorderSettingsStore } from "./playfield-border"; import { AnalysisCursorSettingsStore } from "../analysis/analysis-cursor"; import { ReplayCursorSettingsStore } from "./replay-cursor"; import { SkinHolder, SkinManager, SkinSettingsStore } from "./skin"; import { HitErrorBarSettingsStore } from "./hit-error-bar"; import { PlaybarSettingsStore } from "./playbar"; import { OsuFolderService } from "./local/OsuFolderService"; import { OsuDBDao } from "./local/OsuDBDao"; import { BlueprintLocatorService } from "./local/BlueprintLocatorService"; import { BeatmapRenderService } from "./beatmap-render"; import { STAGE_TYPES } from "../types"; import { AppInfoService } from "./app-info"; /** * Creates the services that support all the osu! tools such as the Analyzer. * Common settings are set here so that they can be shared with other tools. * Example: Preferred skin can be set at only one place and is shared among all tools. */ @injectable() export class CommonManagers { constructor( public readonly skinManager: SkinManager, public readonly skinSettingsStore: SkinSettingsStore, public readonly audioSettingsService: AudioSettingsStore, public readonly beatmapBackgroundSettingsStore: BeatmapBackgroundSettingsStore, public readonly beatmapRenderSettingsStore: BeatmapRenderService, public readonly hitErrorBarSettingsStore: HitErrorBarSettingsStore, public readonly analysisCursorSettingsStore: AnalysisCursorSettingsStore, public readonly replayCursorSettingsStore: ReplayCursorSettingsStore, public readonly playbarSettingsStore: PlaybarSettingsStore, public readonly appInfoService: AppInfoService, ) {} async initialize() { await this.skinManager.loadPreferredSkin(); } } interface Settings { rewindSkinsFolder: string; appVersion: string; appPlatform: string; } export function createRewindTheater({ rewindSkinsFolder, appPlatform, appVersion }: Settings) { // Regarding `skipBaseClassChecks`: https://github.com/inversify/InversifyJS/issues/522#issuecomment-682246076 const container = new Container({ defaultScope: "Singleton" }); container.bind(STAGE_TYPES.AUDIO_CONTEXT).toConstantValue(new AudioContext()); container.bind(STAGE_TYPES.REWIND_SKINS_FOLDER).toConstantValue(rewindSkinsFolder); container.bind(STAGE_TYPES.APP_PLATFORM).toConstantValue(appPlatform); container.bind(STAGE_TYPES.APP_VERSION).toConstantValue(appVersion); container.bind(OsuFolderService).toSelf(); container.bind(OsuDBDao).toSelf(); container.bind(BlueprintLocatorService).toSelf(); container.bind(ReplayService).toSelf(); container.bind(SkinLoader).toSelf(); container.bind(SkinHolder).toSelf(); container.bind(AudioService).toSelf(); container.bind(SkinManager).toSelf(); container.bind(AppInfoService).toSelf(); // General settings stores container.bind(AudioSettingsStore).toSelf(); container.bind(AnalysisCursorSettingsStore).toSelf(); container.bind(BeatmapBackgroundSettingsStore).toSelf(); container.bind(BeatmapRenderService).toSelf(); container.bind(HitErrorBarSettingsStore).toSelf(); container.bind(PlayfieldBorderSettingsStore).toSelf(); container.bind(ReplayCursorSettingsStore).toSelf(); container.bind(SkinSettingsStore).toSelf(); container.bind(PlaybarSettingsStore).toSelf(); // Theater facade container.bind(CommonManagers).toSelf(); return { common: container.get(CommonManagers), analyzer: createRewindAnalysisApp(container), }; } export type RewindTheater = ReturnType; ================================================ FILE: apps/desktop/frontend/src/app/services/common/app-info.ts ================================================ import { inject, injectable } from "inversify"; import { STAGE_TYPES } from "../types"; @injectable() export class AppInfoService { constructor( @inject(STAGE_TYPES.APP_PLATFORM) public readonly platform: string, @inject(STAGE_TYPES.APP_VERSION) public readonly version: string, ) { } } ================================================ FILE: apps/desktop/frontend/src/app/services/common/audio/AudioEngine.ts ================================================ import { inject, injectable, postConstruct } from "inversify"; import { AudioSettings, AudioSettingsStore } from "./settings"; import { STAGE_TYPES } from "../../types"; import { GameplayClock } from "../game/GameplayClock"; // HTML5 Audio supports time stretching without pitch changing (otherwise sounds like night core) // Chromium's implementation of