diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3755f39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# Muzika Gromche — AI Agent Guide + +## Overview + +- **Purpose**: This repository builds a BepInEx plugin (C#) that adds synchronized party music + light effects to Lethal Company, plus a small frontend playground for track metadata and browsing. +- **Two main parts**: the core plugin in `MuzikaGromche/` (targets `netstandard2.1`) and the frontend in `Frontend/` (Vite 3 + Vue + Vitest). +- **Tools and helpers**: there are some helpful Python scripts in the `playground` directory. + +## What to edit / hotspots + +- `MuzikaGromche/Plugin.cs`: plugin entry and initial configuration registration. +- `MuzikaGromche/*.cs` (e.g. `PoweredLights.cs`, `DiscoBallManager.cs`, `SpawnRateManager.cs`, `ScreenFiltersManager.cs`): main game integration and effect logic. +- `UnityAssets/` and `MuzikaGromche/bin/...`: Unity assets and compiled outputs used for packaging. +- Frontend audio/metadata: `Frontend/src/audio/AudioEngine.ts` and `Frontend/public/*` (track lists and audio bundles). +- Sources of audio tracks are edited and mixed outside of this repo, and only final deliveries are checked in here. + +## Build & run (developer workflow) + +- Primary helper: `Justfile` (root) and `MuzikaGromche.just.user` (root, not checked into the repo, so some commands may be unavailable). Common recipes: + - `just build` — runs both `build-debug` and `build-release` (calls `dotnet build`). + - `just build-release` — `dotnet build --configuration Release`. + - `just build-debug` — `dotnet build --configuration Debug`. + - `just build-debug run` — build & run the game for testing, most commonly used combination. + - `just install-imperium` — installs `dist/MuzikaGromche-Debug.zip` into an r2modman/Imperium profile path (see `Justfile` for `plugin_dir`). Not needed, as it is a post-build step anyway. + - `just ogg ` and `just ogg1 ` — custom msbuild targets that convert wav→ogg via `dotnet msbuild /t:wav2ogg` (used when adding tracks from outside of this repo). + - `just loud ` — runs a Python script that measures loudness of an audio track in LUFS, useful to calculate volume adjustments for normalization to a consistent desirable level. + - `just oggloud1 ` and `just oggloud ` — convert and measure loudness in one command invocation (single track, and intro+loop pair of tracks respectively). + +- Frontend: in `Frontend/`: + - Use `pnpm` (lockfile `pnpm-lock.yaml` present). Run `pnpm install`. + - Dev: `pnpm run dev` (Vite). Build: `pnpm run build`. + - Test: `pnpm run test:browser` or `pnpm run coverage`. Uses Vitest with unified config in `vite.config.ts`. + +- Python scripts in `playground`: + - Use `uv` tool to run Python code, or corresponding `just` targets if available. + +## Packaging and outputs + +- Plugin target: `netstandard2.1` — compiled DLLs appear under `MuzikaGromche/bin//netstandard2.1/`. +- The repo expects a `dist/` zip for quick install (see `Justfile` `install-imperium` target). Look for `dist/MuzikaGromche-Debug.zip` when installing into a profile. + +## Conventions & patterns + +- BepInEx plugin pattern: follow `Plugin.cs` for how features are registered and how configuration entries are declared. +- Prefer small localized changes: keep public APIs stable and avoid broad refactors across many `*.cs` files without tests. The game integration is timing-sensitive. +- Manual testing is done by running the game (see `Justfile` `run` target). +- Track assets: audio tracks and timings are curated; if you add tracks, use the `just ogg` or `dotnet msbuild` tasks to convert. Run the game once, so that it dumps a new JSON metadata under the frontend `public/` directory to ensure that data stays in sync. + +## Integration points & external dependencies + +- Game modding: integrates with Lethal Company via BepInEx. The repo assumes you understand how to drop plugin DLLs into r2modman profiles (see `Justfile` `plugin_dir`). +- External tools: `dotnet` (SDK for building and custom msbuild targets), `just` (task runner) — commands are invoked from repository root; `pnpm`/`node` (frontend) — commands are invoked from `Frontend` directory. + +## Examples to reference + +- Change visual effects: edit `MuzikaGromche/PoweredLights.cs` or `MuzikaGromche/ScreenFiltersManager.cs` and test by running `just build-debug run`. +- Add frontend metadata: update `Frontend/public/MuzikaGromcheTracks.json` and run `pnpm run build`. + +## What NOT to assume + +- There are no automated unit tests in the C# mod; it is unreasonably hard to run mod's code outside of Unity runtime, so don't bother with it. Validate changes with local builds and by installing into an r2modman profile. +- Packaging and profile paths are user-specific — `Justfile` contains a template `plugin_dir` that uses `$HOME` and `imperium_profile` fields; do not hardcode absolute paths in commits. + +## Where to look next (quick links) + +- `Justfile` and `MuzikaGromche.just.user` — primary developer recipes and packaging helpers. +- `MuzikaGromche/` — all plugin source code. +- `UnityAssets/` — art/animator resources, treat them as opaque binary blobs. +- `Frontend/` — frontend app, `src/audio/AudioEngine.ts` for audio logic. diff --git a/Frontend/.gitignore b/Frontend/.gitignore new file mode 100644 index 0000000..fea76e2 --- /dev/null +++ b/Frontend/.gitignore @@ -0,0 +1,49 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env +.cache +dist +.idea +.vite-node +ltex* +.DS_Store +.zed + +# tests & coverage +coverage +.vitest-reports +*.tsbuildinfo + +# exclude static html reporter folder +test/browser/html/ +test/core/html/ +.vitest-attachments +explainFiles.txt +.vitest-dump + +# Project assets +/public/MuzikaGromcheAudio/* +!/public/MuzikaGromcheAudio/.gitkeep diff --git a/Frontend/.vscode/extensions.json b/Frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/Frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/Frontend/README.md b/Frontend/README.md new file mode 100644 index 0000000..f84cfa1 --- /dev/null +++ b/Frontend/README.md @@ -0,0 +1,44 @@ +# Muzika Gromche — Web Player & Editor + +Play your favorite tracks on repeat right in your browser. + +## Project structure + +The look & feel is inspired by a certain popular NLE (Non-Linear video Editor) which incorporates DAW (Digital Audio Workstation) functionality in it. + +- Library page lists all available audio tracks with some basic information presented in cards. There is a search / filter field on top. +- Player page features in-depth information about selected audio track, a dedicated timeline widget lets you play, seek, scrub, zoom into timeline tracks & clips, and manupulate them. Each timeline track represents either a segment of the song (intro, loop), or a specific kind of visual effects (such as flickering, fading out & color palette for powered lights, lyrics, drunkness & condensation). + +## Development + +### Adding new tracks + +1. Add track declaration to Plugin.cs and fill in its properties. +2. Launch the game once, so that it generates a new JSON dump (in the Lethal Company save files directory) of all tracks. +3. Replace `public/MuzikaGromcheTracks.json` with the new JSON dump. +4. Run the following script to generate bare codenames file: + ```sh + cat ./MuzikaGromcheTracks.json | jq '[.tracks[].Name | {(.): { "Artist": "", "Song": "" }}] | add' > MuzikaGromcheCodenamesBare.json + ``` +5. Add new codenames from the generated file above to `public/MuzikaGromcheCodenames.json` file. + + +### Run & test + +First time setup: +- copy audio files from the `/Assets/` directory located at repository's root to `Frontend/public/MuzikaGromcheAudio/` directory. + +Muzika Gromche Web Player & Editor is built with Vue 3 + TypeScript + Vite. + +```sh +pnpm run dev +pnpm run test +``` + +### Deploy + +```sh +pnpm run build +``` + +Use scp, rsync or any other tool to upload content of `dist/muzika-gromche/` to root@ratijas.me `/var/www/html/muzika-gromche/`. diff --git a/Frontend/index.html b/Frontend/index.html new file mode 100644 index 0000000..215e957 --- /dev/null +++ b/Frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Muzika Gromche — The ultimate Jester party music mod + + +
+ + + diff --git a/Frontend/package.json b/Frontend/package.json new file mode 100644 index 0000000..7e8b48c --- /dev/null +++ b/Frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "muzika-gromche-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "test": "vitest", + "coverage": "vitest run --coverage", + "test:browser": "vitest" + }, + "dependencies": { + "@material-design-icons/svg": "^0.14.15", + "@tailwindcss/vite": "^4.1.16", + "@vueuse/core": "^14.0.0", + "mitt": "^3.0.1", + "pinia": "^3.0.3", + "tailwindcss": "^4.1.16", + "vue": "^3.5.22", + "vue-router": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vitest/browser-playwright": "^4.0.10", + "@vitest/coverage-v8": "4.0.10", + "@vue/tsconfig": "^0.8.1", + "eslint": "~9.39.1", + "eslint-plugin-vue": "~10.5.1", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.1.14", + "vite-plugin-vue-devtools": "^8.0.3", + "vite-svg-loader": "^5.1.0", + "vitest": "^4.0.10", + "vitest-browser-vue": "^2.0.1", + "vue-tsc": "^3.1.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + }, + "onlyBuiltDependencies": [ + "core-js" + ] + } +} diff --git a/Frontend/pnpm-lock.yaml b/Frontend/pnpm-lock.yaml new file mode 100644 index 0000000..5822594 --- /dev/null +++ b/Frontend/pnpm-lock.yaml @@ -0,0 +1,3514 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.1.14 + +importers: + + .: + dependencies: + '@material-design-icons/svg': + specifier: ^0.14.15 + version: 0.14.15 + '@tailwindcss/vite': + specifier: ^4.1.16 + version: 4.1.16(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + '@vueuse/core': + specifier: ^14.0.0 + version: 14.0.0(vue@3.5.22(typescript@5.9.3)) + mitt: + specifier: ^3.0.1 + version: 3.0.1 + pinia: + specifier: ^3.0.3 + version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)) + tailwindcss: + specifier: ^4.1.16 + version: 4.1.16 + vue: + specifier: ^3.5.22 + version: 3.5.22(typescript@5.9.3) + vue-router: + specifier: ^4.6.3 + version: 4.6.3(vue@3.5.22(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.10.0 + '@vitejs/plugin-vue': + specifier: ^6.0.1 + version: 6.0.1(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vue@3.5.22(typescript@5.9.3)) + '@vitest/browser-playwright': + specifier: ^4.0.10 + version: 4.0.10(playwright@1.56.1)(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10) + '@vitest/coverage-v8': + specifier: 4.0.10 + version: 4.0.10(@vitest/browser@4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10))(vitest@4.0.10) + '@vue/tsconfig': + specifier: ^0.8.1 + version: 0.8.1(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)) + eslint: + specifier: ~9.39.1 + version: 9.39.1(jiti@2.6.1) + eslint-plugin-vue: + specifier: ~10.5.1 + version: 10.5.1(eslint@9.39.1(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.1(jiti@2.6.1))) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.1.14 + version: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + vite-plugin-vue-devtools: + specifier: ^8.0.3 + version: 8.0.3(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vue@3.5.22(typescript@5.9.3)) + vite-svg-loader: + specifier: ^5.1.0 + version: 5.1.0(vue@3.5.22(typescript@5.9.3)) + vitest: + specifier: ^4.0.10 + version: 4.0.10(@types/node@24.10.0)(@vitest/browser-playwright@4.0.10)(jiti@2.6.1) + vitest-browser-vue: + specifier: ^2.0.1 + version: 2.0.1(vitest@4.0.10)(vue@3.5.22(typescript@5.9.3)) + vue-tsc: + specifier: ^3.1.0 + version: 3.1.3(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.5': + resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.27.1': + resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.28.0': + resolution: {integrity: sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.27.1': + resolution: {integrity: sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.5': + resolution: {integrity: sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@emnapi/core@1.7.0': + resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} + + '@emnapi/runtime@1.7.0': + resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@material-design-icons/svg@0.14.15': + resolution: {integrity: sha512-6nbjwGwyJnphwQUscJAYqw1Tk6+W8KvsgOAeyVgzIFXVsHfgX5XyplTUcZ29wbcTUysMMyCUi1LYpmFKA/e61g==} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@oxc-project/runtime@0.92.0': + resolution: {integrity: sha512-Z7x2dZOmznihvdvCvLKMl+nswtOSVxS2H2ocar+U9xx6iMfTp0VGIrX6a4xB1v80IwOPC7dT1LXIJrY70Xu3Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.93.0': + resolution: {integrity: sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.0.0-beta.41': + resolution: {integrity: sha512-Edflndd9lU7JVhVIvJlZhdCj5DkhYDJPIRn4Dx0RUdfc8asP9xHOI5gMd8MesDDx+BJpdIT/uAmVTearteU/mQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.41': + resolution: {integrity: sha512-XGCzqfjdk7550PlyZRTBKbypXrB7ATtXhw/+bjtxnklLQs0mKP/XkQVOKyn9qGKSlvH8I56JLYryVxl0PCvSNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.41': + resolution: {integrity: sha512-Ho6lIwGJed98zub7n0xcRKuEtnZgbxevAmO4x3zn3C3N4GVXZD5xvCvTVxSMoeBJwTcIYzkVDRTIhylQNsTgLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.41': + resolution: {integrity: sha512-ijAZETywvL+gACjbT4zBnCp5ez1JhTRs6OxRN4J+D6AzDRbU2zb01Esl51RP5/8ZOlvB37xxsRQ3X4YRVyYb3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': + resolution: {integrity: sha512-EgIOZt7UildXKFEFvaiLNBXm+4ggQyGe3E5Z1QP9uRcJJs9omihOnm897FwOBQdCuMvI49iBgjFrkhH+wMJ2MA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': + resolution: {integrity: sha512-F8bUwJq8v/JAU8HSwgF4dztoqJ+FjdyjuvX4//3+Fbe2we9UktFeZ27U4lRMXF1vxWtdV4ey6oCSqI7yUrSEeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': + resolution: {integrity: sha512-MioXcCIX/wB1pBnBoJx8q4OGucUAfC1+/X1ilKFsjDK05VwbLZGRgOVD5OJJpUQPK86DhQciNBrfOKDiatxNmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': + resolution: {integrity: sha512-m66M61fizvRCwt5pOEiZQMiwBL9/y0bwU/+Kc4Ce/Pef6YfoEkR28y+DzN9rMdjo8Z28NXjsDPq9nH4mXnAP0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': + resolution: {integrity: sha512-yRxlSfBvWnnfrdtJfvi9lg8xfG5mPuyoSHm0X01oiE8ArmLRvoJGHUTJydCYz+wbK2esbq5J4B4Tq9WAsOlP1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': + resolution: {integrity: sha512-PHVxYhBpi8UViS3/hcvQQb9RFqCtvFmFU1PvUoTRiUdBtgHA6fONNHU4x796lgzNlVSD3DO/MZNk1s5/ozSMQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': + resolution: {integrity: sha512-OAfcO37ME6GGWmj9qTaDT7jY4rM0T2z0/8ujdQIJQ2x2nl+ztO32EIwURfmXOK0U1tzkyuaKYvE34Pug/ucXlQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41': + resolution: {integrity: sha512-NIYGuCcuXaq5BC4Q3upbiMBvmZsTsEPG9k/8QKQdmrch+ocSy5Jv9tdpdmXJyighKqm182nh/zBt+tSJkYoNlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41': + resolution: {integrity: sha512-kANdsDbE5FkEOb5NrCGBJBCaZ2Sabp3D7d4PRqMYJqyLljwh9mDyYyYSv5+QNvdAmifj+f3lviNEUUuUZPEFPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': + resolution: {integrity: sha512-UlpxKmFdik0Y2VjZrgUCgoYArZJiZllXgIipdBRV1hw6uK45UbQabSTW6Kp6enuOu7vouYWftwhuxfpE8J2JAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.29': + resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} + + '@rolldown/pluginutils@1.0.0-beta.41': + resolution: {integrity: sha512-ycMEPrS3StOIeb87BT3/+bu+blEtyvwQ4zmo2IcJQy0Rd1DAAhKksA0iUZ3MYSpJtjlPhg0Eo6mvVS6ggPhRbw==} + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@tailwindcss/node@4.1.16': + resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} + + '@tailwindcss/oxide-android-arm64@4.1.16': + resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.16': + resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.16': + resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.16': + resolution: {integrity: sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.10.0': + resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@6.0.1': + resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + vue: ^3.2.25 + + '@vitest/browser-playwright@4.0.10': + resolution: {integrity: sha512-pm7Hl7BNyluox+uGJPnT7vCRDSI+ibHcWQRtnCACAZWxD6/b2gN+8pO0qTDPHpxDSTPKDS5sT2dKTHLcr+lsng==} + peerDependencies: + playwright: '*' + vitest: 4.0.10 + + '@vitest/browser@4.0.10': + resolution: {integrity: sha512-irO+aGxYx/rAhjEBLsGPO4JQ8dA+A43enIST0j4xQ2kYHatHi9tUcxkRRGpClGuUVU42mi+iQsFFzd4xxpoV3g==} + peerDependencies: + vitest: 4.0.10 + + '@vitest/coverage-v8@4.0.10': + resolution: {integrity: sha512-g+brmtoKa/sAeIohNJnnWhnHtU6GuqqVOSQ4SxDIPcgZWZyhJs5RmF5LpqXs8Kq64lANP+vnbn5JLzhLj/G56g==} + peerDependencies: + '@vitest/browser': 4.0.10 + vitest: 4.0.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.10': + resolution: {integrity: sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==} + + '@vitest/mocker@4.0.10': + resolution: {integrity: sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.10': + resolution: {integrity: sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==} + + '@vitest/runner@4.0.10': + resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} + + '@vitest/snapshot@4.0.10': + resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} + + '@vitest/spy@4.0.10': + resolution: {integrity: sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==} + + '@vitest/utils@4.0.10': + resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} + + '@volar/language-core@2.4.23': + resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} + + '@volar/source-map@2.4.23': + resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==} + + '@volar/typescript@2.4.23': + resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==} + + '@vue/babel-helper-vue-transform-on@1.5.0': + resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==} + + '@vue/babel-plugin-jsx@1.5.0': + resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-resolve-type@1.5.0': + resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.5.22': + resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==} + + '@vue/compiler-dom@3.5.22': + resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==} + + '@vue/compiler-sfc@3.5.22': + resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==} + + '@vue/compiler-ssr@3.5.22': + resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.7': + resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==} + + '@vue/devtools-core@8.0.3': + resolution: {integrity: sha512-gCEQN7aMmeaigEWJQ2Z2o3g7/CMqGTPvNS1U3n/kzpLoAZ1hkAHNgi4ml/POn/9uqGILBk65GGOUdrraHXRj5Q==} + peerDependencies: + vue: ^3.0.0 + + '@vue/devtools-kit@7.7.7': + resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} + + '@vue/devtools-kit@8.0.3': + resolution: {integrity: sha512-UF4YUOVGdfzXLCv5pMg2DxocB8dvXz278fpgEE+nJ/DRALQGAva7sj9ton0VWZ9hmXw+SV8yKMrxP2MpMhq9Wg==} + + '@vue/devtools-shared@7.7.7': + resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} + + '@vue/devtools-shared@8.0.3': + resolution: {integrity: sha512-s/QNll7TlpbADFZrPVsaUNPCOF8NvQgtgmmB7Tip6pLf/HcOvBTly0lfLQ0Eylu9FQ4OqBhFpLyBgwykiSf8zw==} + + '@vue/language-core@3.1.3': + resolution: {integrity: sha512-KpR1F/eGAG9D1RZ0/T6zWJs6dh/pRLfY5WupecyYKJ1fjVmDMgTPw9wXmKv2rBjo4zCJiOSiyB8BDP1OUwpMEA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.22': + resolution: {integrity: sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==} + + '@vue/runtime-core@3.5.22': + resolution: {integrity: sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==} + + '@vue/runtime-dom@3.5.22': + resolution: {integrity: sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==} + + '@vue/server-renderer@3.5.22': + resolution: {integrity: sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==} + peerDependencies: + vue: 3.5.22 + + '@vue/shared@3.5.22': + resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==} + + '@vue/test-utils@2.4.6': + resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + + '@vue/tsconfig@0.8.1': + resolution: {integrity: sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@vueuse/core@14.0.0': + resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.0.0': + resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==} + + '@vueuse/shared@14.0.0': + resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==} + peerDependencies: + vue: ^3.5.0 + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + alien-signals@3.0.5: + resolution: {integrity: sha512-+2bRQFO1f9GLeIabDQWJlluL1NspZlLjpjaSSwwpl+9Tz5tS/3KrceHdwjNvIMEbYWSpoqtOPuXLTSoPgvIEWw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.8: + resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.25: + resolution: {integrity: sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==} + hasBin: true + + birpc@2.6.1: + resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + browserslist@4.27.0: + resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001754: + resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.0: + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} + engines: {node: '>=18'} + + default-browser@5.2.1: + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + + electron-to-chromium@1.5.247: + resolution: {integrity: sha512-bCkfEJNE5EOhEsGpgbvgUAXYP/uAZeN9GCd9bPGjm2/uiLdpoiZnxrBTbWT1nbZs1y6UqN7hatM3hOB4X1kXUQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-vue@10.5.1: + resolution: {integrity: sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.1: + resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.0.0: + resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pinia@3.0.3: + resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown-vite@7.1.14: + resolution: {integrity: sha512-eSiiRJmovt8qDJkGyZuLnbxAOAdie6NCmmd0NkTC0RJI9duiSBTfr8X2mBYJOUFzxQa2USaHmL99J9uMxkjCyw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.41: + resolution: {integrity: sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + superjson@2.2.5: + resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + tailwindcss@4.1.16: + resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-dev-rpc@1.1.0: + resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 + + vite-hot-client@2.1.0: + resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==} + peerDependencies: + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 + + vite-plugin-inspect@11.3.3: + resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + vite-plugin-vue-devtools@8.0.3: + resolution: {integrity: sha512-yIi3u31xUi28HcLlTpV0BvSLQHgZ2dA8Zqa59kWfIeMdHqbsunt6TCjq4wCNfOcGSju+E7qyHyI09EjRRFMbuQ==} + engines: {node: '>=v14.21.3'} + peerDependencies: + vite: ^6.0.0 || ^7.0.0-0 + + vite-plugin-vue-inspector@5.3.2: + resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==} + peerDependencies: + vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 + + vite-svg-loader@5.1.0: + resolution: {integrity: sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==} + peerDependencies: + vue: '>=3.2.13' + + vitest-browser-vue@2.0.1: + resolution: {integrity: sha512-IfTJY3Olr27AXCVAhOqx4g5iUgzdWLmYr40svg7rOrunjsqc9trrzi/eI3i11+UYddbKXu3HJl2bo8o8lqKm4A==} + peerDependencies: + vitest: ^4.0.0-0 + vue: ^3.0.0 + + vitest@4.0.10: + resolution: {integrity: sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.10 + '@vitest/browser-preview': 4.0.10 + '@vitest/browser-webdriverio': 4.0.10 + '@vitest/ui': 4.0.10 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@2.2.12: + resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + + vue-eslint-parser@10.2.0: + resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + vue-router@4.6.3: + resolution: {integrity: sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@3.1.3: + resolution: {integrity: sha512-StMNfZHwPIXQgY3KxPKM0Jsoc8b46mDV3Fn2UlHCBIwRJApjqrSwqeMYgWf0zpN+g857y74pv7GWuBm+UqQe1w==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.22: + resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.27.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.5 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@emnapi/core@1.7.0': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': + dependencies: + eslint: 9.39.1(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.1': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@material-design-icons/svg@0.14.15': {} + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.7.0 + '@emnapi/runtime': 1.7.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@one-ini/wasm@0.1.1': {} + + '@oxc-project/runtime@0.92.0': {} + + '@oxc-project/types@0.93.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.0.0-beta.41': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.41': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.41': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.41': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.29': {} + + '@rolldown/pluginutils@1.0.0-beta.41': {} + + '@standard-schema/spec@1.0.0': {} + + '@tailwindcss/node@4.1.16': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.16 + + '@tailwindcss/oxide-android-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide@4.1.16': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-x64': 4.1.16 + '@tailwindcss/oxide-freebsd-x64': 4.1.16 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.16 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-x64-musl': 4.1.16 + '@tailwindcss/oxide-wasm32-wasi': 4.1.16 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 + + '@tailwindcss/vite@4.1.16(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))': + dependencies: + '@tailwindcss/node': 4.1.16 + '@tailwindcss/oxide': 4.1.16 + tailwindcss: 4.1.16 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + + '@trysound/sax@0.2.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.10.0': + dependencies: + undici-types: 7.16.0 + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@6.0.1(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.29 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + vue: 3.5.22(typescript@5.9.3) + + '@vitest/browser-playwright@4.0.10(playwright@1.56.1)(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10)': + dependencies: + '@vitest/browser': 4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10) + '@vitest/mocker': 4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + playwright: 1.56.1 + tinyrainbow: 3.0.3 + vitest: 4.0.10(@types/node@24.10.0)(@vitest/browser-playwright@4.0.10)(jiti@2.6.1) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10)': + dependencies: + '@vitest/mocker': 4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + '@vitest/utils': 4.0.10 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.10(@types/node@24.10.0)(@vitest/browser-playwright@4.0.10)(jiti@2.6.1) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.0.10(@vitest/browser@4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10))(vitest@4.0.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.10 + ast-v8-to-istanbul: 0.3.8 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.10(@types/node@24.10.0)(@vitest/browser-playwright@4.0.10)(jiti@2.6.1) + optionalDependencies: + '@vitest/browser': 4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.0.10': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.10 + '@vitest/utils': 4.0.10 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 4.0.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + + '@vitest/pretty-format@4.0.10': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.10': + dependencies: + '@vitest/utils': 4.0.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.10': + dependencies: + '@vitest/pretty-format': 4.0.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.10': {} + + '@vitest/utils@4.0.10': + dependencies: + '@vitest/pretty-format': 4.0.10 + tinyrainbow: 3.0.3 + + '@volar/language-core@2.4.23': + dependencies: + '@volar/source-map': 2.4.23 + + '@volar/source-map@2.4.23': {} + + '@volar/typescript@2.4.23': + dependencies: + '@volar/language-core': 2.4.23 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/babel-helper-vue-transform-on@1.5.0': {} + + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.28.5)': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@vue/babel-helper-vue-transform-on': 1.5.0 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.28.5) + '@vue/shared': 3.5.22 + optionalDependencies: + '@babel/core': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.28.5)': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/parser': 7.28.5 + '@vue/compiler-sfc': 3.5.22 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.22': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.22 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.22': + dependencies: + '@vue/compiler-core': 3.5.22 + '@vue/shared': 3.5.22 + + '@vue/compiler-sfc@3.5.22': + dependencies: + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.22 + '@vue/compiler-dom': 3.5.22 + '@vue/compiler-ssr': 3.5.22 + '@vue/shared': 3.5.22 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.22': + dependencies: + '@vue/compiler-dom': 3.5.22 + '@vue/shared': 3.5.22 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.7': + dependencies: + '@vue/devtools-kit': 7.7.7 + + '@vue/devtools-core@8.0.3(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@vue/devtools-kit': 8.0.3 + '@vue/devtools-shared': 8.0.3 + mitt: 3.0.1 + nanoid: 5.1.6 + pathe: 2.0.3 + vite-hot-client: 2.1.0(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + vue: 3.5.22(typescript@5.9.3) + transitivePeerDependencies: + - vite + + '@vue/devtools-kit@7.7.7': + dependencies: + '@vue/devtools-shared': 7.7.7 + birpc: 2.6.1 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.5 + + '@vue/devtools-kit@8.0.3': + dependencies: + '@vue/devtools-shared': 8.0.3 + birpc: 2.6.1 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 2.0.0 + speakingurl: 14.0.1 + superjson: 2.2.5 + + '@vue/devtools-shared@7.7.7': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.0.3': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@3.1.3(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.23 + '@vue/compiler-dom': 3.5.22 + '@vue/shared': 3.5.22 + alien-signals: 3.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.3 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.22': + dependencies: + '@vue/shared': 3.5.22 + + '@vue/runtime-core@3.5.22': + dependencies: + '@vue/reactivity': 3.5.22 + '@vue/shared': 3.5.22 + + '@vue/runtime-dom@3.5.22': + dependencies: + '@vue/reactivity': 3.5.22 + '@vue/runtime-core': 3.5.22 + '@vue/shared': 3.5.22 + csstype: 3.1.3 + + '@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.22 + '@vue/shared': 3.5.22 + vue: 3.5.22(typescript@5.9.3) + + '@vue/shared@3.5.22': {} + + '@vue/test-utils@2.4.6': + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + + '@vue/tsconfig@0.8.1(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.22(typescript@5.9.3) + + '@vueuse/core@14.0.0(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.0.0 + '@vueuse/shared': 14.0.0(vue@3.5.22(typescript@5.9.3)) + vue: 3.5.22(typescript@5.9.3) + + '@vueuse/metadata@14.0.0': {} + + '@vueuse/shared@14.0.0(vue@3.5.22(typescript@5.9.3))': + dependencies: + vue: 3.5.22(typescript@5.9.3) + + abbrev@2.0.0: {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.0.5: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.8: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.25: {} + + birpc@2.6.1: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + browserslist@4.27.0: + dependencies: + baseline-browser-mapping: 2.8.25 + caniuse-lite: 1.0.30001754 + electron-to-chromium: 1.5.247 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.27.0) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001754: {} + + chai@6.2.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + commander@7.2.0: {} + + concat-map@0.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + convert-source-map@2.0.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + csstype@3.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + default-browser-id@5.0.0: {} + + default-browser@5.2.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + + define-lazy-prop@3.0.0: {} + + detect-libc@2.1.2: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + eastasianwidth@0.2.0: {} + + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.7.3 + + electron-to-chromium@1.5.247: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@1.7.0: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-vue@10.5.1(eslint@9.39.1(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.1(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + eslint: 9.39.1(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.7.3 + vue-eslint-parser: 10.2.0(eslint@9.39.1(jiti@2.6.1)) + xml-name-validator: 4.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.1(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.39.1 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.2.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hookable@5.5.3: {} + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-what@5.5.0: {} + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.6.1: {} + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.5.0 + js-cookie: 3.0.5 + nopt: 7.2.1 + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + mitt@3.0.1: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.11: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.27: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + ohash@2.0.11: {} + + open@10.2.0: + dependencies: + default-browser: 5.2.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.7 + vue: 3.5.22(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + resolve-from@4.0.0: {} + + rfdc@1.4.1: {} + + rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1): + dependencies: + '@oxc-project/runtime': 0.92.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.41 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.0 + fsevents: 2.3.3 + jiti: 2.6.1 + + rolldown@1.0.0-beta.41: + dependencies: + '@oxc-project/types': 0.93.0 + '@rolldown/pluginutils': 1.0.0-beta.41 + ansis: 4.2.0 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.41 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.41 + '@rolldown/binding-darwin-x64': 1.0.0-beta.41 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.41 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.41 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.41 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.41 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.41 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.41 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.41 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.41 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.41 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.41 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.41 + + run-applescript@7.1.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@3.1.1: {} + + superjson@2.2.5: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.2.2 + css-tree: 2.3.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + + tailwindcss@4.1.16: {} + + tapable@2.3.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + totalist@3.0.1: {} + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.3 + + update-browserslist-db@1.1.4(browserslist@4.27.0): + dependencies: + browserslist: 4.27.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-dev-rpc@1.1.0(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)): + dependencies: + birpc: 2.6.1 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + vite-hot-client: 2.1.0(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + + vite-hot-client@2.1.0(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)): + dependencies: + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + + vite-plugin-inspect@11.3.3(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)): + dependencies: + ansis: 4.2.0 + debug: 4.4.3 + error-stack-parser-es: 1.0.5 + ohash: 2.0.11 + open: 10.2.0 + perfect-debounce: 2.0.0 + sirv: 3.0.2 + unplugin-utils: 0.3.1 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + vite-dev-rpc: 1.1.0(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + vite-plugin-vue-devtools@8.0.3(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vue@3.5.22(typescript@5.9.3)): + dependencies: + '@vue/devtools-core': 8.0.3(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vue@3.5.22(typescript@5.9.3)) + '@vue/devtools-kit': 8.0.3 + '@vue/devtools-shared': 8.0.3 + sirv: 3.0.2 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + vite-plugin-inspect: 11.3.3(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + transitivePeerDependencies: + - '@nuxt/kit' + - supports-color + - vue + + vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)): + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) + '@vue/compiler-dom': 3.5.22 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + transitivePeerDependencies: + - supports-color + + vite-svg-loader@5.1.0(vue@3.5.22(typescript@5.9.3)): + dependencies: + svgo: 3.3.2 + vue: 3.5.22(typescript@5.9.3) + + vitest-browser-vue@2.0.1(vitest@4.0.10)(vue@3.5.22(typescript@5.9.3)): + dependencies: + '@vue/test-utils': 2.4.6 + vitest: 4.0.10(@types/node@24.10.0)(@vitest/browser-playwright@4.0.10)(jiti@2.6.1) + vue: 3.5.22(typescript@5.9.3) + + vitest@4.0.10(@types/node@24.10.0)(@vitest/browser-playwright@4.0.10)(jiti@2.6.1): + dependencies: + '@vitest/expect': 4.0.10 + '@vitest/mocker': 4.0.10(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1)) + '@vitest/pretty-format': 4.0.10 + '@vitest/runner': 4.0.10 + '@vitest/snapshot': 4.0.10 + '@vitest/spy': 4.0.10 + '@vitest/utils': 4.0.10 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.0 + '@vitest/browser-playwright': 4.0.10(playwright@1.56.1)(rolldown-vite@7.1.14(@types/node@24.10.0)(jiti@2.6.1))(vitest@4.0.10) + transitivePeerDependencies: + - esbuild + - jiti + - less + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vscode-uri@3.1.0: {} + + vue-component-type-helpers@2.2.12: {} + + vue-eslint-parser@10.2.0(eslint@9.39.1(jiti@2.6.1)): + dependencies: + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.22(typescript@5.9.3) + + vue-tsc@3.1.3(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.23 + '@vue/language-core': 3.1.3(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.22(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.22 + '@vue/compiler-sfc': 3.5.22 + '@vue/runtime-dom': 3.5.22 + '@vue/server-renderer': 3.5.22(vue@3.5.22(typescript@5.9.3)) + '@vue/shared': 3.5.22 + optionalDependencies: + typescript: 5.9.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + ws@8.18.3: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 + + xml-name-validator@4.0.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/Frontend/public/MuzikaGromcheAudio/.gitkeep b/Frontend/public/MuzikaGromcheAudio/.gitkeep new file mode 100644 index 0000000..69228eb --- /dev/null +++ b/Frontend/public/MuzikaGromcheAudio/.gitkeep @@ -0,0 +1 @@ +Copy /Assets/ to this directory. diff --git a/Frontend/public/MuzikaGromcheCodenames.json b/Frontend/public/MuzikaGromcheCodenames.json new file mode 100644 index 0000000..69e3dbf --- /dev/null +++ b/Frontend/public/MuzikaGromcheCodenames.json @@ -0,0 +1,110 @@ +{ + "AttentionPls1": { + "Artist": "Отпетые Мошенники", + "Song": "Обратите внимание" + }, + "AttentionPls2": { + "Artist": "Отпетые Мошенники", + "Song": "Обратите внимание" + }, + "BbIXODaHET": { + "Artist": "Сплин", + "Song": "Выхода нет" + }, + "BeefLiver1": { + "Artist": "Imagine Dragons", + "Song": "Believer" + }, + "BeefLiver3": { + "Artist": "Imagine Dragons", + "Song": "Believer" + }, + "BeefLiver4": { + "Artist": "Imagine Dragons", + "Song": "Believer" + }, + "Beha1": { + "Artist": "Жу-Жу", + "Song": "Ленинград ft. Глюк'oZа ft. ST" + }, + "Beha2": { + "Artist": "Жу-Жу", + "Song": "Ленинград ft. Глюк'oZа ft. ST" + }, + "Beha3": { + "Artist": "Жу-Жу", + "Song": "Ленинград ft. Глюк'oZа ft. ST" + }, + "Chereshnya": { + "Artist": "Дискотека Авария", + "Song": "Малинки" + }, + "DeployDestroy": { + "Artist": "Noize MC", + "Song": "Устрой дестрой" + }, + "Durochka": { + "Artist": "Би-2", + "Song": "Дурочка" + }, + "GodMode": { + "Artist": "Fall Out Boy", + "Song": "Immortals" + }, + "Gorgorod": { + "Artist": "Город под подошвой", + "Song": "Oxxxymiron" + }, + "Kach": { + "Artist": "Black Eyed Peas", + "Song": "Pump It" + }, + "MoyaZhittya": { + "Artist": "Bon Jovi", + "Song": "It's My Life" + }, + "MuzikaGromche": { + "Artist": "Пошлая Молли", + "Song": "Нон стоп" + }, + "OnePartiyaUdar": { + "Artist": "One-Punch Man", + "Song": "Opening" + }, + "Peretasovka": { + "Artist": "LMFAO", + "Song": "Party Rock Anthem" + }, + "PWNED": { + "Artist": "CYBEЯIA", + "Song": "Russian Hackers" + }, + "ReelGoon": { + "Artist": "John Shanks and Sheryl Crow", + "Song": "Real Gone" + }, + "RiseAndShine": { + "Artist": "Fall Out Boy", + "Song": "The Phoenix" + }, + "Song2": { + "Artist": "Витас", + "Song": "Опера #2" + }, + "VseVZale": { + "Artist": "Дискотека Авария", + "Song": " Х.Х.Х.И.Р.Н.Р." + }, + "Whistle": { + "Artist": "Flo Rida", + "Song": "Whistle" + }, + "Yalgaar": { + "Artist": "Ajey Nagar and Wily Frenzy", + "Song": "Yalgaar" + }, + "ZmeiGorynich": { + "Artist": "aespa", + "Song": "Black Mamba" + } +} diff --git a/Frontend/public/MuzikaGromcheTracks.json b/Frontend/public/MuzikaGromcheTracks.json new file mode 100644 index 0000000..2ae9352 --- /dev/null +++ b/Frontend/public/MuzikaGromcheTracks.json @@ -0,0 +1,2419 @@ +{ + "version": "1337.9001.2", + "tracks": [ + { + "Name": "AttentionPls1", + "IsExplicit": true, + "Language": "Russian", + "WindUpTimer": 39.19, + "Bpm": 97.8244247, + "Beats": 32, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 49.061, + "FileDurationLoop": 19.627, + "FileNameIntro": "AttentionPls1Intro.ogg", + "FileNameLoop": "AttentionPlsLoop.ogg", + "BeatsOffset": 0.3, + "FadeOutBeat": -6.0, + "FadeOutDuration": 5.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -8.0, + 31.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [ + [ + 7.0, + 0.0 + ], + [ + 12.0, + 0.9 + ], + [ + 15.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [ + [ + 23.0, + 0.0 + ], + [ + 28.0, + 0.4 + ], + [ + 31.0, + 0.0 + ] + ], + "Palette": [ + "#FCEB3C", + "#FC3C9D", + "#65C7FA", + "#89FC8F", + "#FEE9E9", + "#FCEB3C", + "#89FC8F", + "#FC3C9D" + ], + "GameOverText": null + }, + { + "Name": "AttentionPls2", + "IsExplicit": true, + "Language": "Russian", + "WindUpTimer": 39.19, + "Bpm": 97.8244247, + "Beats": 32, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 49.061, + "FileDurationLoop": 19.627, + "FileNameIntro": "AttentionPls2Intro.ogg", + "FileNameLoop": "AttentionPlsLoop.ogg", + "BeatsOffset": 0.3, + "FadeOutBeat": -6.0, + "FadeOutDuration": 5.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -8.0, + 31.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [ + [ + 7.0, + 0.0 + ], + [ + 12.0, + 0.9 + ], + [ + 15.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [ + [ + 23.0, + 0.0 + ], + [ + 28.0, + 0.4 + ], + [ + 31.0, + 0.0 + ] + ], + "Palette": [ + "#FCEB3C", + "#FC3C9D", + "#65C7FA", + "#89FC8F", + "#FEE9E9", + "#FCEB3C", + "#89FC8F", + "#FC3C9D" + ], + "GameOverText": null + }, + { + "Name": "BbIXODaHET", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 40.85, + "Bpm": 84.82064, + "Beats": 32, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 53.368, + "FileDurationLoop": 22.636, + "FileNameIntro": "BbIXODaHETIntro.ogg", + "FileNameLoop": "BbIXODaHETLoop.ogg", + "BeatsOffset": 0.3, + "FadeOutBeat": -6.0, + "FadeOutDuration": 6.0, + "ColorTransitionIn": 0.7, + "ColorTransitionOut": 0.3, + "ColorTransitionEasing": "InOutCubic", + "FlickerLightsTimeSeries": [ + -32.5, + -16.5, + 30.5 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [ + [ + -1.0, + 0.0 + ], + [ + 2.0, + 0.4 + ], + [ + 7.0, + 0.0 + ], + [ + 31.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#E6D58F", + "#612F7E", + "#D9783F", + "#C3411C", + "#D3B742", + "#549BDE" + ], + "GameOverText": null + }, + { + "Name": "BeefLiver1", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 39.35, + "Bpm": 124.999992, + "Beats": 48, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 43.166, + "FileDurationLoop": 23.04, + "FileNameIntro": "BeefLiver1Intro.ogg", + "FileNameLoop": "BeefLiverLoop.ogg", + "BeatsOffset": 0.2, + "FadeOutBeat": -3.0, + "FadeOutDuration": 3.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -48.0, + -40.0, + -4.5, + 44.0 + ], + "Lyrics": [ + [ + -66.0, + "First things first" + ], + [ + -62.0, + "First things first,\nI'ma say all the words\ninside my head" + ], + [ + -57.0, + "I'm fired up and tired of" + ], + [ + -52.0, + "the way that things have been,\noh-ooh" + ], + [ + -44.0, + "(x2)\nThe way that things have been,\noh-ooooh" + ], + [ + -34.0, + "I was broken from a young age, taking my sulkin' to the masses" + ], + [ + -27.0, + "Writing my poems for the few" + ], + [ + -23.0, + "that look at me, took to me,\nshook at me, feelin' me" + ], + [ + -19.0, + "Singing from heartache from the pain" + ], + [ + -15.0, + "Singing from heartache from the pain,\ntaking my message from the veins" + ], + [ + -11.0, + "Speaking my lesson from the brain" + ], + [ + -8.0, + "Speaking my lesson from the brain,\nseeing the beauty through the" + ], + [ + -0.1, + "PAIN!" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -0.5, + 0.0 + ], + [ + 0.5, + 0.6 + ], + [ + 8.0, + 0.0 + ], + [ + 15.0, + 0.0 + ], + [ + 16.0, + 0.4 + ], + [ + 24.0, + 0.0 + ], + [ + 29.0, + 0.0 + ], + [ + 30.0, + 0.3 + ], + [ + 36.0, + 0.0 + ], + [ + 37.0, + 0.0 + ], + [ + 38.0, + 0.3 + ], + [ + 44.0, + 0.0 + ], + [ + 47.5, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FFEBEB", + "#FFEBEB", + "#445782", + "#EBA602", + "#5EEBB9", + "#8EE3DC", + "#A23045", + "#262222" + ], + "GameOverText": null + }, + { + "Name": "BeefLiver3", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 39.35, + "Bpm": 124.999992, + "Beats": 48, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 43.166, + "FileDurationLoop": 23.04, + "FileNameIntro": "BeefLiver3Intro.ogg", + "FileNameLoop": "BeefLiverLoop.ogg", + "BeatsOffset": 0.2, + "FadeOutBeat": -3.0, + "FadeOutDuration": 3.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -48.0, + -40.0, + -4.5, + 44.0 + ], + "Lyrics": [ + [ + -66.0, + "Third things third" + ], + [ + -62.0, + "Third things third,\nsend a prayer to the ones up above" + ], + [ + -57.0, + "All the hate that you've heard has turned" + ], + [ + -52.0, + "your spirit to a dove,\noh-ooh" + ], + [ + -44.0, + "(x2)\nYour spirit up above,\noh-ooooh" + ], + [ + -34.0, + "I was chokin' in the crowd, building my rain up in the cloud" + ], + [ + -27.0, + "Falling like ashes to the ground" + ], + [ + -23.0, + "hoping my feelings, they would drown" + ], + [ + -19.0, + "But they never did, ever lived, ebbin' and flowin'" + ], + [ + -15.0, + "Inhibited, limited 'til it broke open" + ], + [ + -11.0, + "Inhibited, limited 'til it broke open and rained down" + ], + [ + -8.0, + "It rained down like" + ], + [ + -0.1, + "PAIN!" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -0.5, + 0.0 + ], + [ + 0.5, + 0.6 + ], + [ + 8.0, + 0.0 + ], + [ + 15.0, + 0.0 + ], + [ + 16.0, + 0.4 + ], + [ + 24.0, + 0.0 + ], + [ + 29.0, + 0.0 + ], + [ + 30.0, + 0.3 + ], + [ + 36.0, + 0.0 + ], + [ + 37.0, + 0.0 + ], + [ + 38.0, + 0.3 + ], + [ + 44.0, + 0.0 + ], + [ + 47.5, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FFEBEB", + "#FFEBEB", + "#445782", + "#EBA602", + "#5EEBB9", + "#8EE3DC", + "#A23045", + "#262222" + ], + "GameOverText": null + }, + { + "Name": "BeefLiver4", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 31.68, + "Bpm": 124.999992, + "Beats": 48, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 35.487, + "FileDurationLoop": 23.04, + "FileNameIntro": "BeefLiver4Intro.ogg", + "FileNameLoop": "BeefLiver4Loop.ogg", + "BeatsOffset": 0.2, + "FadeOutBeat": -3.0, + "FadeOutDuration": 3.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -32.0, + -24.0, + -4.5, + 44.0 + ], + "Lyrics": [ + [ + -50.0, + "Last things last" + ], + [ + -46.0, + "Last things last,\nby the grace\nof the fire and the flames" + ], + [ + -41.0, + "You're the face of the future" + ], + [ + -36.0, + "the blood in my veins, oh-ooh" + ], + [ + -28.0, + "(x2)\nThe blood in my veins, oh-ooooh" + ], + [ + -19.0, + "But they never did, ever lived, ebbin' and flowin'" + ], + [ + -15.0, + "Inhibited, limited 'til it broke open" + ], + [ + -11.0, + "Inhibited, limited 'til it broke open and rained down" + ], + [ + -8.0, + "It rained down like" + ], + [ + -0.1, + "PAIN!" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -0.5, + 0.0 + ], + [ + 0.5, + 0.6 + ], + [ + 8.0, + 0.0 + ], + [ + 15.0, + 0.0 + ], + [ + 16.0, + 0.4 + ], + [ + 24.0, + 0.0 + ], + [ + 29.0, + 0.0 + ], + [ + 30.0, + 0.3 + ], + [ + 36.0, + 0.0 + ], + [ + 37.0, + 0.0 + ], + [ + 38.0, + 0.3 + ], + [ + 44.0, + 0.0 + ], + [ + 47.5, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FFEBEB", + "#FFEBEB", + "#445782", + "#EBA602", + "#5EEBB9", + "#8EE3DC", + "#A23045", + "#262222" + ], + "GameOverText": null + }, + { + "Name": "Beha1", + "IsExplicit": true, + "Language": "Russian", + "WindUpTimer": 35.23, + "Bpm": 81.99027, + "Beats": 34, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 47.15, + "FileDurationLoop": 24.881, + "FileNameIntro": "Beha1Intro.ogg", + "FileNameLoop": "BehaLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -4.0, + "FadeOutDuration": 3.9, + "ColorTransitionIn": 0.1, + "ColorTransitionOut": 0.6, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -6.0, + 16.5 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#9554F9", + "#3769FD", + "#E43B65", + "#59CFEA", + "#7F3FEE", + "#C831FE" + ], + "GameOverText": null + }, + { + "Name": "Beha2", + "IsExplicit": true, + "Language": "Russian", + "WindUpTimer": 38.16, + "Bpm": 81.99027, + "Beats": 34, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 50.064, + "FileDurationLoop": 24.881, + "FileNameIntro": "Beha2Intro.ogg", + "FileNameLoop": "BehaLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -4.0, + "FadeOutDuration": 3.9, + "ColorTransitionIn": 0.1, + "ColorTransitionOut": 0.6, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -6.0, + 16.5 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#9554F9", + "#3769FD", + "#E43B65", + "#59CFEA", + "#7F3FEE", + "#C831FE" + ], + "GameOverText": null + }, + { + "Name": "Beha3", + "IsExplicit": true, + "Language": "Russian", + "WindUpTimer": 35.21, + "Bpm": 81.99027, + "Beats": 34, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 47.111, + "FileDurationLoop": 24.881, + "FileNameIntro": "Beha3Intro.ogg", + "FileNameLoop": "BehaLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -4.0, + "FadeOutDuration": 3.9, + "ColorTransitionIn": 0.1, + "ColorTransitionOut": 0.6, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -6.0, + 16.5 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#9554F9", + "#3769FD", + "#E43B65", + "#59CFEA", + "#7F3FEE", + "#C831FE" + ], + "GameOverText": null + }, + { + "Name": "Chereshnya", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 45.48, + "Bpm": 131.958755, + "Beats": 64, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 46.15, + "FileDurationLoop": 29.1, + "FileNameIntro": "ChereshnyaIntro.ogg", + "FileNameLoop": "ChereshnyaLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -4.0, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.3, + "ColorTransitionOut": 0.35, + "ColorTransitionEasing": "InOutCubic", + "FlickerLightsTimeSeries": [ + -5.0, + 27.0, + 29.0, + 59.0, + 61.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#A01471", + "#CB2243", + "#4CAF50", + "#F01D7A", + "#AF005A", + "#EF355F", + "#FFD85D", + "#FF66B2", + "#A01471", + "#4CAF50", + "#CB2243", + "#F01D7A", + "#AF005A", + "#FFD85D", + "#EF355F", + "#FF66B2" + ], + "GameOverText": null + }, + { + "Name": "DeployDestroy", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 40.68, + "Bpm": 129.878922, + "Beats": 32, + "LoopOffset": 32, + "Ext": "ogg", + "FileDurationIntro": 57.283, + "FileDurationLoop": 14.783, + "FileNameIntro": "DeployDestroyIntro.ogg", + "FileNameLoop": "DeployDestroyLoop.ogg", + "BeatsOffset": 0.2, + "FadeOutBeat": -38.0, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.25, + "ColorTransitionOut": 0.25, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -101.0, + -93.0, + -77.0, + -61.0, + -37.0, + -5.0, + 27.0 + ], + "Lyrics": [ + [ + -111.0, + "Deploy Destroy, porjadok eto otstoj" + ], + [ + -103.0, + "Krushi, lomaj, trjasi bashkoju pustoj" + ], + [ + -95.0, + "Dopej, razbej i novuju otkryvaj" + ], + [ + -87.0, + "Davaj-davaj!" + ], + [ + -79.0, + "Chestnoe slovo ja nevinoven" + ], + [ + -75.0, + "Ja ne pomnju, otkuda stol'ko krovi" + ], + [ + -71.0, + "Na moih ladonjah\nyi moej odezhde" + ], + [ + -67.0, + "Ja nikogda nikogo\nne bil prezhde" + ], + [ + -63.0, + "Ja nikogda nichego\nne pil prezhde" + ], + [ + -59.0, + "Byl tih, spokoen,\nso vsemi vezhliv" + ], + [ + -55.0, + "Vsegda tol'ko v urnu\nbrosal musor" + ], + [ + -51.0, + "Obhodil storonoj shumnye tusy" + ], + [ + -47.0, + "Zapreshhjonnyh veshhestv nikakih ne juzal" + ], + [ + -43.0, + "Byl polozhitel'nej samogo pljusa" + ], + [ + -39.0, + "A potom kak-to raz\njetu pesnju uslyshal" + ], + [ + -35.0, + "I vsjo proshhaj, moja krysha" + ], + [ + -31.0, + "Deploy Destroy, porjadok eto otstoj" + ], + [ + -23.0, + "Krushi, lomaj, trjasi bashkoju pustoj" + ], + [ + -15.0, + "Dopej, razbej i novuju otkryvaj" + ], + [ + -7.0, + "Davaj-davaj!" + ], + [ + 1.0, + "Deploy Destroy, porjadok eto otstoj" + ], + [ + 9.0, + "Krushi, lomaj, trjasi bashkoju pustoj" + ], + [ + 17.0, + "Dopej, razbej i novuju otkryvaj" + ], + [ + 25.0, + "Davaj-davaj!" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -48.0, + 0.0 + ], + [ + -46.0, + 0.7 + ], + [ + -42.0, + 0.0 + ], + [ + 16.0, + 0.0 + ], + [ + 19.0, + 0.3 + ], + [ + 23.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#217F87", + "#BAFF00", + "#73BE25", + "#78AB4E", + "#FFFF00" + ], + "GameOverText": null + }, + { + "Name": "Durochka", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 37.0, + "Bpm": 129.9686, + "Beats": 40, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 38.833, + "FileDurationLoop": 18.466, + "FileNameIntro": "DurochkaIntro.ogg", + "FileNameLoop": "DurochkaLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -7.0, + "FadeOutDuration": 7.0, + "ColorTransitionIn": 0.25, + "ColorTransitionOut": 0.3, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -9.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#5986FE", + "#FEFEDC", + "#FF4FDF", + "#FEFEDC", + "#FFAA23", + "#FEFEDC", + "#F95A5A", + "#FEFEDC" + ], + "GameOverText": null + }, + { + "Name": "GodMode", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 40.38, + "Bpm": 108.016876, + "Beats": 64, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 42.6, + "FileDurationLoop": 35.55, + "FileNameIntro": "GodModeIntro.ogg", + "FileNameLoop": "GodModeLoop.ogg", + "BeatsOffset": 0.1, + "FadeOutBeat": -4.0, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.5, + "ColorTransitionOut": 0.5, + "ColorTransitionEasing": "OutCubic", + "FlickerLightsTimeSeries": [ + -5.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [ + [ + -0.5, + 0.0 + ], + [ + 0.0, + 0.7 + ], + [ + 8.0, + 0.0 + ], + [ + 63.5, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FBDBDB", + "#4B81FF", + "#564242", + "#C90AE2", + "#FBDBDB", + "#61CBE3", + "#564242", + "#ED3131" + ], + "GameOverText": "[COULD'VE BEEN: IMMORTAL]" + }, + { + "Name": "Gorgorod", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 43.2, + "Bpm": 90.0, + "Beats": 24, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 47.866, + "FileDurationLoop": 16.0, + "FileNameIntro": "GorgorodIntro.ogg", + "FileNameLoop": "GorgorodLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -2.0, + "FadeOutDuration": 2.0, + "ColorTransitionIn": 0.25, + "ColorTransitionOut": 0.25, + "ColorTransitionEasing": "InExpo", + "FlickerLightsTimeSeries": [ + 20.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#42367E", + "#FF9400", + "#932A04", + "#FF9400", + "#932A04", + "#42367E", + "#FF9400", + "#932A04" + ], + "GameOverText": null + }, + { + "Name": "Kach", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 47.3, + "Bpm": 153.6, + "Beats": 48, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 48.866, + "FileDurationLoop": 18.75, + "FileNameIntro": "KachIntro.ogg", + "FileNameLoop": "KachLoop.ogg", + "BeatsOffset": 0.4, + "FadeOutBeat": -6.0, + "FadeOutDuration": 6.0, + "ColorTransitionIn": 0.8, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -120.5, + -105.0, + -89.0, + -8.0, + 44.0, + 45.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#7774DE", + "#1EA59A", + "#3BC457", + "#3BC457", + "#CA6935", + "#A82615", + "#A7AA43", + "#A7AA43", + "#4C2B81", + "#2E802B", + "#C952E7", + "#C952E7" + ], + "GameOverText": "[DIDN'T PUMP IT: LOUDER]" + }, + { + "Name": "MoyaZhittya", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 34.53, + "Bpm": 120.0, + "Beats": 32, + "LoopOffset": 32, + "Ext": "ogg", + "FileDurationIntro": 43.533, + "FileDurationLoop": 16.0, + "FileNameIntro": "MoyaZhittyaIntro.ogg", + "FileNameLoop": "MoyaZhittyaLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -35.0, + "FadeOutDuration": 3.3, + "ColorTransitionIn": 0.25, + "ColorTransitionOut": 0.5, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -100.5, + -99.5, + -92.5, + -91.5, + -76.5, + -75.5, + -60.5, + -59.5, + -37.0, + -36.0, + -4.5, + -3.5, + 27.5, + 28.5 + ], + "Lyrics": [ + [ + -84.0, + "This ain't a song for the broken-hearted" + ], + [ + -68.0, + "No silent prayer for the faith-departed" + ], + [ + -52.0, + "I ain't gonna be" + ], + [ + -48.0, + "I ain't gonna be\njust a face in the crowd" + ], + [ + -45.0, + "YOU'RE" + ], + [ + -44.0, + "you're GONNA" + ], + [ + -43.5, + "you're gonna HEAR" + ], + [ + -43.0, + "you're gonna hear\nMY" + ], + [ + -42.0, + "you're gonna hear\nmy VOICE" + ], + [ + -41.0, + "WHEN I" + ], + [ + -40.0, + "When I SHOUT IT" + ], + [ + -39.0, + "When I shout it\nOUT LOUD" + ], + [ + -34.0, + "IT'S MY" + ], + [ + -32.0, + "IT'S MY\nLIIIIIFE" + ], + [ + -28.0, + "And it's now or never" + ], + [ + -22.0, + "I ain't gonna" + ], + [ + -20.0, + "I ain't gonna\nlive forever" + ], + [ + -14.0, + "I just want to live" + ], + [ + -10.0, + "I just want to live\nwhile I'm alive" + ], + [ + -2.0, + "IT'S MY" + ], + [ + 0.0, + "IT'S MY\nLIIIIIFE" + ], + [ + 2.0, + "My heart is like" + ], + [ + 4.0, + "My heart is like\nan open highway" + ], + [ + 10.0, + "Like Frankie said," + ], + [ + 12.0, + "Like Frankie said,\n\"I did it my way\"" + ], + [ + 18.0, + "I just want to live" + ], + [ + 22.0, + "I just want to live\nwhile I'm alive" + ], + [ + 30.0, + "IT'S MY" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -33.0, + 0.0 + ], + [ + -31.0, + 0.7 + ], + [ + -1.0, + 0.0 + ], + [ + 1.0, + 0.7 + ], + [ + 8.0, + 0.0 + ], + [ + 24.0, + 0.0 + ], + [ + 31.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#A8C480", + "#3ABBBE", + "#6E9855", + "#4C6846", + "#748084", + "#058099" + ], + "GameOverText": "[LIFE IS: NOW OR NEVER]" + }, + { + "Name": "MuzikaGromche", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 46.3, + "Bpm": 129.729721, + "Beats": 64, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 50.283, + "FileDurationLoop": 29.6, + "FileNameIntro": "MuzikaGromcheIntro.ogg", + "FileNameLoop": "MuzikaGromcheLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -3.0, + "FadeOutDuration": 3.0, + "ColorTransitionIn": 0.25, + "ColorTransitionOut": 0.25, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -5.0, + 29.0, + 61.0 + ], + "Lyrics": [ + [ + -68.0, + "Devchata pljashut pod spidami" + ], + [ + -60.0, + "A ty stoish', kak vkopannyj" + ], + [ + -52.0, + "Krossovkami lomajut pol" + ], + [ + -44.0, + "A ty stoish', kak vkopannyj" + ], + [ + -36.0, + "Ja-ja-ja znaju, chto ty hochesh'," + ], + [ + -32.0, + "Ja-ja-ja znaju, chto ty hochesh',\nTy hochesh' tancevat'" + ], + [ + -28.0, + "Nu-nu zhe, nu davaj zhe," + ], + [ + -24.0, + "Nu-nu zhe, nu davaj zhe,\nNu-nu zhe, nu davaj zhe" + ], + [ + -20.0, + "Ja znaju, chto ty znaesh'\nJetot trek, gotov'sja podpevat'" + ], + [ + -12.0, + "1) RAZ" + ], + [ + -10.0, + "raz, DVA" + ], + [ + -8.0, + "raz, 2wo,\nTRI" + ], + [ + -6.0, + "ras, dva,\n7ri, 4ETYRE" + ], + [ + -1.0, + "Muzyka Gromche\nGlaza zakryty >_<" + ], + [ + 6.0, + "This is NON-STOP,\nNoch'ju otkrytij" + ], + [ + 12.0, + "Delaj chto hochesh', ja zabyvajus'" + ], + [ + 22.0, + "This is NON-STOP,\nne prekrashhajas'" + ], + [ + 31.0, + "Muzyka Gromche\nGlaza zakryty -.-" + ], + [ + 38.0, + "This is NON-STOP,\nNoch'ju otkrytij" + ], + [ + 46.0, + "Budu s toboju,\nsamoj primernoju" + ], + [ + 54.0, + "Utro v okne\nyi my budem pervye" + ], + [ + 63.0, + "Muzyka Gromche\nGlaza zakryty >_<" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -2.0, + 0.0 + ], + [ + 0.0, + 0.4 + ], + [ + 1.0, + 0.6 + ], + [ + 3.0, + 0.0 + ], + [ + 30.0, + 0.0 + ], + [ + 32.0, + 0.5 + ], + [ + 33.0, + 0.7 + ], + [ + 35.0, + 0.0 + ], + [ + 62.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#B300FF", + "#FFF100", + "#00FF51", + "#474747", + "#FF00B3", + "#0070FF" + ], + "GameOverText": null + }, + { + "Name": "OnePartiyaUdar", + "IsExplicit": false, + "Language": "Japanese", + "WindUpTimer": 41.27, + "Bpm": 130.06955, + "Beats": 48, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 44.983, + "FileDurationLoop": 22.142, + "FileNameIntro": "OnePartiyaUdarIntro.ogg", + "FileNameLoop": "OnePartiyaUdarLoop.ogg", + "BeatsOffset": 0.3, + "FadeOutBeat": -8.0, + "FadeOutDuration": 6.0, + "ColorTransitionIn": 0.6, + "ColorTransitionOut": 0.15, + "ColorTransitionEasing": "InOutExpo", + "FlickerLightsTimeSeries": [ + -68.5, + -16.5, + 30.5 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#9C3C37", + "#E9BF5C", + "#B5E3EA", + "#662422", + "#EBC3A8", + "#AA8238" + ], + "GameOverText": null + }, + { + "Name": "Peretasovka", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 39.68, + "Bpm": 130.612244, + "Beats": 32, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 40.6, + "FileDurationLoop": 14.7, + "FileNameIntro": "PeretasovkaIntro.ogg", + "FileNameLoop": "PeretasovkaLoop.ogg", + "BeatsOffset": 0.3, + "FadeOutBeat": -6.0, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -8.0, + 31.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#65C7FA", + "#FCEB3C", + "#89FC8F", + "#FEE9E9", + "#FC3C9D", + "#FCEB3C", + "#89FC8F", + "#FC3C9D" + ], + "GameOverText": null + }, + { + "Name": "PWNED", + "IsExplicit": true, + "Language": "English", + "WindUpTimer": 39.73, + "Bpm": 289.8113, + "Beats": 128, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 59.633, + "FileDurationLoop": 26.5, + "FileNameIntro": "PWNEDIntro.ogg", + "FileNameLoop": "PWNEDLoop.ogg", + "BeatsOffset": -0.2, + "FadeOutBeat": -8.0, + "FadeOutDuration": 6.0, + "ColorTransitionIn": 0.5, + "ColorTransitionOut": 0.3, + "ColorTransitionEasing": "InExpo", + "FlickerLightsTimeSeries": [ + -136.0, + -72.0, + -12.0, + 88.0 + ], + "Lyrics": [ + [ + -190.0, + "These Russian hackers have been" + ], + [ + -184.0, + "in these US governments\nsince March" + ], + [ + -172.0, + "and it is an extraordinary invasion of our cyberspace" + ], + [ + -152.0, + "Russian hackers got access to sensitive" + ], + [ + -142.0, + "parts of the White House email system..." + ], + [ + -134.0, + "[They began to recognize...]" + ], + [ + -126.0, + "" + ], + [ + -118.0, + "\n X__X" + ], + [ + -110.0, + "Gonna crack your" + ], + [ + -102.0, + "Gonna crack your\nStrongest pa$$words%123" + ], + [ + -94.0, + "You popped online" + ], + [ + -86.0, + "You popped online\nTo look for sneakers" + ], + [ + -78.0, + "My hand just popped" + ], + [ + -70.0, + "My hand just popped\nRight in your knickers >_< " + ], + [ + -62.0, + "Keystrokes like Uzi" + ], + [ + -54.0, + "Keystrokes like Uzi\nWill make you go all goosey" + ], + [ + -46.0, + "Kicking down your backdoor" + ], + [ + -38.0, + "Kicking down your backdoor\nCount down before you lose it" + ], + [ + -30.0, + "Keystrokes like Uzi" + ], + [ + -22.0, + "Keystrokes like Uzi\nWill make you go all goosey" + ], + [ + -14.0, + "Kicking down your backdoor" + ], + [ + -6.0, + "Kicking down your backdoor\nCount down before you lose it" + ], + [ + 0.0, + "C:\\> $Ru55ian hack3rs" + ], + [ + 4.0, + "C:\\> $Ru55ian hack3rs\n O__o" + ], + [ + 8.0, + "Infamous White House attackers" + ], + [ + 16.0, + "Stealing your cookies\nto this beat" + ], + [ + 24.0, + "Counting crypto to\nembarrass Wall Street" + ], + [ + 32.0, + "Russi?n ^hackers\tЯushan h@ckers###" + ], + [ + 34.0, + "\tЯushan h@ckers###\n X_X" + ], + [ + 36.0, + "Russi?n ^hackers\n--.--\tЯushan h@ckers###\n X___X" + ], + [ + 38.0, + "\tЯushan h@ckers###\n X_____X" + ], + [ + 40.0, + "Infamous White House attackers" + ], + [ + 48.0, + "Stealing your cookies\nto this beat" + ], + [ + 56.0, + "Counting crypto to\nembarrass Wall Street" + ], + [ + 80.0, + "Instling min3r.exe\t\t\tresolving ur private IP\n/" + ], + [ + 82.0, + "Instling min3r.exe\n00% [8=D ]\tHenllo ${username = \"user\"}\t\tresolving ur private IP\n-" + ], + [ + 84.0, + "Instling min3r.exe\n33% [8====D ]\t\t\tresolving ur private IP\n\\" + ], + [ + 86.0, + "Instling min3r.exe\n66% [8=========D ]\t\t\tresolving ur private IP\n|" + ], + [ + 88.0, + "Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n/" + ], + [ + 90.0, + "Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n-" + ], + [ + 92.0, + "Encrpt1ng f!les.. \n99% [8=============D]\t\t\tresolving ur private IP\n\\ Trying... 127.0.0.1" + ], + [ + 94.0, + "Encrpt1ng f!les...\n100% enj0y \\o/\t\t\tresolving ur private IP\n| Trying... 127.0.0.1" + ], + [ + 96.0, + "\t\t\tresolving ur private IP\n/ Trying... 127.0.0.1" + ], + [ + 98.0, + "\t\t\tresolving ur private IP\nP_WNED" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -128.0, + 0.0 + ], + [ + -127.0, + 0.7 + ], + [ + -116.0, + 0.0 + ], + [ + 68.0, + 0.0 + ], + [ + 72.0, + 0.3 + ], + [ + 88.0, + 0.5 + ], + [ + 98.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FFFFFF", + "#0032A0", + "#DA291C", + "#000000", + "#9E9E9E", + "#9E9E9E", + "#383838", + "#383838", + "#5E5E5E", + "#5E5E5E", + "#2E2E2E", + "#2E2E2E", + "#666666", + "#666666", + "#4B4B4B", + "#4B4B4B", + "#8E8E8E", + "#8E8E8E", + "#1D1D1D", + "#1D1D1D", + "#9E9E9E", + "#9E9E9E", + "#383838", + "#383838", + "#5E5E5E", + "#5E5E5E", + "#2E2E2E", + "#2E2E2E", + "#666666", + "#666666", + "#4B4B4B", + "#4B4B4B", + "#FFFFFF", + "#0032A0", + "#DA291C", + "#000000", + "#9E9E9E", + "#9E9E9E", + "#383838", + "#383838", + "#5E5E5E", + "#5E5E5E", + "#2E2E2E", + "#2E2E2E", + "#666666", + "#666666", + "#4B4B4B", + "#4B4B4B", + "#8E8E8E", + "#8E8E8E", + "#1D1D1D", + "#1D1D1D", + "#9E9E9E", + "#9E9E9E", + "#383838", + "#383838", + "#5E5E5E", + "#5E5E5E", + "#2E2E2E", + "#2E2E2E", + "#666666", + "#666666", + "#4B4B4B", + "#4B4B4B", + "#9E9E9E", + "#9E9E9E", + "#9E9E9E", + "#9E9E9E", + "#383838", + "#383838", + "#383838", + "#383838", + "#5E5E5E", + "#5E5E5E", + "#5E5E5E", + "#5E5E5E", + "#2E2E2E", + "#2E2E2E", + "#2E2E2E", + "#2E2E2E", + "#666666", + "#666666", + "#666666", + "#666666", + "#4B4B4B", + "#4B4B4B", + "#4B4B4B", + "#4B4B4B", + "#8E8E8E", + "#8E8E8E", + "#8E8E8E", + "#8E8E8E", + "#1D1D1D", + "#1D1D1D", + "#1D1D1D", + "#1D1D1D", + "#9E9E9E", + "#9E9E9E", + "#9E9E9E", + "#9E9E9E", + "#383838", + "#383838", + "#383838", + "#383838", + "#5E5E5E", + "#5E5E5E", + "#5E5E5E", + "#5E5E5E", + "#2E2E2E", + "#2E2E2E", + "#2E2E2E", + "#2E2E2E", + "#666666", + "#666666", + "#666666", + "#666666", + "#4B4B4B", + "#4B4B4B", + "#4B4B4B", + "#4B4B4B", + "#8E8E8E", + "#8E8E8E", + "#8E8E8E", + "#8E8E8E", + "#1D1D1D", + "#1D1D1D", + "#1D1D1D", + "#1D1D1D" + ], + "GameOverText": "[HACK3D BY: RUSSI4NS]" + }, + { + "Name": "ReelGoon", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 45.15, + "Bpm": 117.997726, + "Beats": 64, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 47.45, + "FileDurationLoop": 32.543, + "FileNameIntro": "ReelGoonIntro.ogg", + "FileNameLoop": "ReelGoonLoop.ogg", + "BeatsOffset": -0.35, + "FadeOutBeat": -2.0, + "FadeOutDuration": 2.0, + "ColorTransitionIn": 0.1, + "ColorTransitionOut": 0.35, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -41.0, + 61.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [ + [ + -0.5, + 0.0 + ], + [ + -0.05, + 0.5 + ], + [ + 6.0, + 0.0 + ], + [ + 60.0, + 0.0 + ], + [ + 61.0, + 0.5 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#DE1C31", + "#F7E26B", + "#3D3D3D", + "#FBB040", + "#ED4E4A", + "#F0BD37", + "#E41E2E", + "#2E2D2B" + ], + "GameOverText": "[LIFE SUPPORT: REAL GONE]" + }, + { + "Name": "RiseAndShine", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 59.87, + "Bpm": 137.8815, + "Beats": 64, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 61.616, + "FileDurationLoop": 27.85, + "FileNameIntro": "RiseAndShineIntro.ogg", + "FileNameLoop": "RiseAndShineLoop.ogg", + "BeatsOffset": 0.1, + "FadeOutBeat": -4.5, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.5, + "ColorTransitionOut": 0.5, + "ColorTransitionEasing": "OutCubic", + "FlickerLightsTimeSeries": [ + -5.5, + 31.0, + 63.9 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [ + [ + -0.5, + 0.0 + ], + [ + 0.0, + 0.7 + ], + [ + 8.0, + 0.0 + ], + [ + 63.5, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FC6F3C", + "#3CB0FC", + "#FCD489", + "#564242", + "#FC6F3C", + "#3CB0FC", + "#63E98C", + "#866868" + ], + "GameOverText": "[ HEY, YOUNG BLOOD ]" + }, + { + "Name": "Song2", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 38.63, + "Bpm": 50.0, + "Beats": 34, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 43.433, + "FileDurationLoop": 40.8, + "FileNameIntro": "Song2Intro.ogg", + "FileNameLoop": "Song2Loop.ogg", + "BeatsOffset": 0.1, + "FadeOutBeat": -2.0, + "FadeOutDuration": 2.0, + "ColorTransitionIn": 0.3, + "ColorTransitionOut": 0.3, + "ColorTransitionEasing": "InCubic", + "FlickerLightsTimeSeries": [ + 2.5 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FFD3E3", + "#78A0FF", + "#FFD3E3", + "#74A392", + "#FFD3E3", + "#E4B082", + "#FFD3E3", + "#E277AA" + ], + "GameOverText": null + }, + { + "Name": "VseVZale", + "IsExplicit": false, + "Language": "Russian", + "WindUpTimer": 38.28, + "Bpm": 137.965729, + "Beats": 64, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 40.016, + "FileDurationLoop": 27.833, + "FileNameIntro": "VseVZaleIntro.ogg", + "FileNameLoop": "VseVZaleLoop.ogg", + "BeatsOffset": 0.25, + "FadeOutBeat": -3.0, + "FadeOutDuration": 2.5, + "ColorTransitionIn": 0.25, + "ColorTransitionOut": 0.25, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -5.0, + 29.0, + 59.0 + ], + "Lyrics": [ + [ + -30.0, + "VSE V ZALE\nDvigajtes' s nami" + ], + [ + -24.0, + "Chtob sotrjasalis'\nSami my, steny i pol!" + ], + [ + -14.0, + "Vse znaem - jeto examen na dom nam zadan" + ], + [ + -4.0, + "HIP-HOP, HOUSE & ROCK-N-ROLL" + ], + [ + 2.0, + "VSE V ZALE\nDvigajtes' s nami" + ], + [ + 8.0, + "Chtob sotrjasalis'\nSami my, steny i pol!" + ], + [ + 18.0, + "Vse znaem - jeto examen na dom nam zadan" + ], + [ + 28.0, + "HIP-HOP, HOUSE & ROCK-N-ROLL" + ], + [ + 32.0, + "O-o-o-o! Zdes' startuet hip-hop party" + ], + [ + 44.0, + "Tolstyj paren', nam igraj!" + ], + [ + 48.0, + "O-o-o-o! Pesen i devchonok hvatit!" + ], + [ + 60.0, + "Everybody shake your body" + ] + ], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#FF7F00", + "#FFB600", + "#FFED00", + "#00D1FF", + "#6696FB", + "#704DF8", + "#FF7F00", + "#FFB600", + "#FFED00", + "#00D1FF", + "#6696FB", + "#704DF8", + "#FF7F00", + "#FFB600", + "#FFED00", + "#00D1FF", + "#6696FB", + "#704DF8", + "#FF7F00", + "#FFB600", + "#FFED00", + "#00D1FF", + "#6696FB", + "#704DF8", + "#FF7F00", + "#FFB600", + "#FFED00", + "#00D1FF", + "#6696FB", + "#704DF8", + "#FF7F00", + "#FFB600", + "#FFED00", + "#FFED00", + "#00D1FF", + "#00D1FF", + "#6696FB", + "#6696FB", + "#704DF8", + "#704DF8", + "#FF7F00", + "#FF7F00", + "#FFB600", + "#FFB600", + "#FFED00", + "#FFED00", + "#00D1FF", + "#00D1FF", + "#6696FB", + "#6696FB", + "#704DF8", + "#704DF8", + "#FF7F00", + "#FF7F00", + "#FFB600", + "#FFB600", + "#FFED00", + "#FFED00", + "#00D1FF", + "#00D1FF", + "#6696FB", + "#6696FB", + "#704DF8", + "#704DF8" + ], + "GameOverText": null + }, + { + "Name": "Whistle", + "IsExplicit": false, + "Language": "English", + "WindUpTimer": 41.27, + "Bpm": 104.016182, + "Beats": 48, + "LoopOffset": 16, + "Ext": "ogg", + "FileDurationIntro": 52.433, + "FileDurationLoop": 27.688, + "FileNameIntro": "WhistleIntro.ogg", + "FileNameLoop": "WhistleLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -22.0, + "FadeOutDuration": 6.0, + "ColorTransitionIn": 0.5, + "ColorTransitionOut": 0.2, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -20.0, + 12.0 + ], + "Lyrics": [ + [ + -40.0, + "Can you blow my whistle, baby, whistle, baby?" + ], + [ + -36.0, + "Can you blow my whistle, baby, whistle, baby? Let me know" + ], + [ + -33.5, + "Girl, I'm gonna show you how to\ndo it" + ], + [ + -30.5, + "Girl, I'm gonna show you how to\ndo it and we start real slow" + ], + [ + -27.0, + "You just put your lips together" + ], + [ + -24.0, + "You just put your lips together and you come real close" + ], + [ + -21.0, + "Can you blow my whistle, baby, whistle, baby?" + ], + [ + -17.0, + "HERE WE GO" + ], + [ + 10.0, + "Yeah, baby, make that whistle" + ], + [ + 12.0, + "Yeah, baby, make that whistle\nblow oh oh oh" + ], + [ + 15.0, + "Can you blow my whistle, baby, whistle, baby?" + ], + [ + 20.0, + "Can you blow my whistle, baby, whistle, baby? Let me know" + ], + [ + 23.0, + "Girl, I'm gonna show you how to\ndo it" + ], + [ + 28.0, + "Girl, I'm gonna show you how to\ndo it and we start real slow" + ], + [ + 32.0, + "You just put your lips together" + ], + [ + 36.0, + "You just put your lips together and you come real close" + ], + [ + 39.0, + "Can you blow my whistle, baby, whistle, baby?" + ], + [ + 46.0, + "HERE" + ], + [ + 47.0, + "Here WE" + ], + [ + 48.0, + "Here we GO" + ] + ], + "DrunknessLoopOffsetTimeSeries": [ + [ + -16.0, + 0.0 + ], + [ + -15.25, + 0.7 + ], + [ + -12.0, + 0.0 + ], + [ + 9.0, + 0.0 + ], + [ + 15.0, + 0.4 + ], + [ + 16.0, + 0.7 + ], + [ + 18.0, + 0.4 + ], + [ + 21.0, + 0.0 + ] + ], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#8DDEDD", + "#98DE28", + "#E8DB4B", + "#F060A8", + "#EEC263", + "#725DEB" + ], + "GameOverText": null + }, + { + "Name": "Yalgaar", + "IsExplicit": false, + "Language": "Hindi", + "WindUpTimer": 52.17, + "Bpm": 92.0157242, + "Beats": 32, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 53.466, + "FileDurationLoop": 20.866, + "FileNameIntro": "YalgaarIntro.ogg", + "FileNameLoop": "YalgaarLoop.ogg", + "BeatsOffset": 0.0, + "FadeOutBeat": -4.0, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.1, + "ColorTransitionOut": 0.35, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -5.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#C0402D", + "#906F0B", + "#DC8044", + "#70190A", + "#929FAF", + "#4248A2", + "#AE2727", + "#2D2D42" + ], + "GameOverText": null + }, + { + "Name": "ZmeiGorynich", + "IsExplicit": false, + "Language": "Korean", + "WindUpTimer": 46.13, + "Bpm": 90.0014, + "Beats": 32, + "LoopOffset": 0, + "Ext": "ogg", + "FileDurationIntro": 48.8, + "FileDurationLoop": 21.333, + "FileNameIntro": "ZmeiGorynichIntro.ogg", + "FileNameLoop": "ZmeiGorynichLoop.ogg", + "BeatsOffset": 0.1, + "FadeOutBeat": -4.0, + "FadeOutDuration": 4.0, + "ColorTransitionIn": 0.4, + "ColorTransitionOut": 0.4, + "ColorTransitionEasing": "OutExpo", + "FlickerLightsTimeSeries": [ + -5.0, + 31.0 + ], + "Lyrics": [], + "DrunknessLoopOffsetTimeSeries": [], + "CondensationLoopOffsetTimeSeries": [], + "Palette": [ + "#4C8AC5", + "#AF326A", + "#0B1666", + "#AFD2FC", + "#C55297", + "#540070" + ], + "GameOverText": "[MUZIKA: K-POP GROMCHE]" + } + ] +} \ No newline at end of file diff --git a/Frontend/src/App.vue b/Frontend/src/App.vue new file mode 100644 index 0000000..6970906 --- /dev/null +++ b/Frontend/src/App.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/Frontend/src/assets/playhead-main.png b/Frontend/src/assets/playhead-main.png new file mode 100644 index 0000000..1e43c43 Binary files /dev/null and b/Frontend/src/assets/playhead-main.png differ diff --git a/Frontend/src/assets/playhead-top.png b/Frontend/src/assets/playhead-top.png new file mode 100644 index 0000000..15c38dd Binary files /dev/null and b/Frontend/src/assets/playhead-top.png differ diff --git a/Frontend/src/assets/vue.svg b/Frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/Frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/src/audio/AudioEngine.ts b/Frontend/src/audio/AudioEngine.ts new file mode 100644 index 0000000..9910641 --- /dev/null +++ b/Frontend/src/audio/AudioEngine.ts @@ -0,0 +1,245 @@ +/* + AudioEngine.ts + A small singleton wrapper around the WebAudio AudioContext that: + - lazily creates the AudioContext + - provides fetch/decode + simple caching + - schedules intro and loop buffers to play seamlessly + - exposes play/pause/stop and a volume control via a master GainNode + - provides a short fade-in/out GainNode to avoid clicks (few ms) + - exposes getPosition() to read current playback time relative to intro start +*/ + +export const VOLUME_MAX: number = 1.5; + +class AudioEngine { + audioCtx: AudioContext | null = null; + masterGain: GainNode | null = null; // controlled by UI volume slider + fadeGain: GainNode | null = null; // tiny fade to avoid clicks + + // cache of decoded buffers by URL + bufferCache = new Map(); + + // currently playing nodes + currentIntro: AudioBufferSourceNode | null = null; + currentLoop: AudioBufferSourceNode | null = null; + + introDuration: number | undefined = undefined; + loopDuration: number | undefined = undefined; + + // timing bookkeeping + introStartTime: number | undefined = undefined; // audioCtx.currentTime when intro started + playedDuration: number | undefined = undefined; // seconds elapsed at pause + stoppingTimeoutID: number | undefined = undefined; // running setTimeout for fade out and cleanup + shuttingDown: boolean = false; + + // settings + fadeDuration = 0.025; // 25 ms for fade-in/fade-out + + init() { + if (this.shuttingDown) return; + if (this.audioCtx) return; + this.audioCtx = + new (window.AudioContext || (window as any).webkitAudioContext)(); + + this.masterGain = this.audioCtx.createGain(); + this.fadeGain = this.audioCtx.createGain(); + + // routing: sources -> fadeGain -> masterGain -> destination + this.fadeGain.connect(this.masterGain); + this.masterGain.connect(this.audioCtx.destination); + + // default full volume + this.masterGain.gain.value = 1; + this.fadeGain.gain.value = 1; + + this.currentIntro = null; + this.currentLoop = null; + + this.introStartTime = undefined; + this.playedDuration = undefined; + + if (this.stoppingTimeoutID !== undefined) { + clearTimeout(this.stoppingTimeoutID); + this.stoppingTimeoutID = undefined; + } + if (this.shuttingDown) { + this.shuttingDown = false; + } + } + + shutdown() { + this.pause({ shutdown: true }); + } + + async fetchAudioBuffer( + url: string, + signal?: AbortSignal, + ): Promise { + this.init(); + if (this.bufferCache.has(url)) return this.bufferCache.get(url)!; + const res = await fetch(url, { signal }); + if (!res.ok) { + throw new Error(`Network error ${res.status} when fetching ${url}`); + } + const arrayBuffer = await res.arrayBuffer(); + const audioBuffer = await this.audioCtx!.decodeAudioData(arrayBuffer); + this.bufferCache.set(url, audioBuffer); + return audioBuffer; + } + + // set UI volume 0..VOLUME_MAX + setVolume(value: number) { + this.init(); + if (!this.masterGain || !this.audioCtx) return; + const now = this.audioCtx.currentTime; + // small linear ramp to avoid jumps + this.masterGain.gain.cancelScheduledValues(now); + this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now); + this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05); + } + + private fadeOutNow(fade = this.fadeDuration) { + if (!this.audioCtx || !this.fadeGain) return; + const now = this.audioCtx.currentTime; + const end = now + fade; + this.fadeGain.gain.cancelScheduledValues(now); + this.fadeGain.gain.setValueAtTime(this.fadeGain.gain.value, now); + this.fadeGain.gain.linearRampToValueAtTime(0.0001, end); + } + + private fadeInNow(fade = this.fadeDuration) { + if (!this.audioCtx || !this.fadeGain) return; + const now = this.audioCtx.currentTime; + const end = now + fade; + this.fadeGain.gain.cancelScheduledValues(now); + this.fadeGain.gain.setValueAtTime(0.0001, now); + this.fadeGain.gain.linearRampToValueAtTime(1, end); + } + + // Play intro then seamlessly transition to loop (loop=true). + // Normally tracks have an intro buffer and a loop buffer. If the requested + // start offset falls after the intro duration, we skip playing the intro + // and start the loop immediately with a calculated offset into the loop. + // offset = seconds into the composite timeline (intro + loop) where playback should start + playBuffers(introBuffer: AudioBuffer, loopBuffer: AudioBuffer, position = 0) { + if (this.shuttingDown) return; + this.init(); + if (!this.audioCtx || !this.fadeGain) return; + + // stop any previous nodes + this.stopNodes(); + // abort the clean up after fade out which would've stopped our newly created nodes + clearTimeout(this.stoppingTimeoutID); + this.stoppingTimeoutID = undefined; + + const now = this.audioCtx.currentTime; + + this.introDuration = introBuffer.duration; + this.loopDuration = loopBuffer.duration; + + // figure out where to start + if (position < this.introDuration) { + // start intro with offset, schedule loop after remaining intro time + const introOffset = position; + const timeUntilLoop = this.introDuration - introOffset; + + const introNode = this.audioCtx.createBufferSource(); + introNode.buffer = introBuffer; + introNode.connect(this.fadeGain); + introNode.start(now, introOffset); + + const loopNode = this.audioCtx.createBufferSource(); + loopNode.buffer = loopBuffer; + loopNode.loop = true; + loopNode.connect(this.fadeGain); + loopNode.start(now + timeUntilLoop, 0); + + this.currentIntro = introNode; + this.currentLoop = loopNode; + this.introStartTime = now - position; + } else { + // start directly in loop with proper offset into loop + const loopOffset = (position - this.introDuration) % this.loopDuration; + const loopNode = this.audioCtx.createBufferSource(); + loopNode.buffer = loopBuffer; + loopNode.loop = true; + loopNode.connect(this.fadeGain!); + loopNode.start(now, loopOffset); + + this.currentIntro = null; + this.currentLoop = loopNode; + this.introStartTime = now - this.introDuration - loopOffset; + } + + // brief fade to avoid clicks + this.fadeInNow(); + this.playedDuration = undefined; + } + + pause({ shutdown = false }: { shutdown?: boolean } = {}) { + if (!this.audioCtx) return; + if (this.shuttingDown) return; + this.shuttingDown = shutdown; + // capture played duration at stop time before the fade + // (the fade is just an effect which does not affect core timing logic) + this.playedDuration = this.getPosition(); + this.introStartTime = undefined; + // fade quickly then stop + this.fadeOutNow(); + // schedule stop slightly after fade to avoid cutting off + const stopAfterMs = (this.fadeDuration + 0.005) * 1000; + // clean up after the fade, but leave a hatch to abort the clean up + this.stoppingTimeoutID = setTimeout(() => { + this.stopNodes(); + if (this.shuttingDown) { + this.stopEngine(); + } + }, stopAfterMs); + } + + private stopNodes() { + try { + this.currentIntro?.stop(); + } catch (e) { + /* ignore */ + } + try { + this.currentLoop?.stop(); + } catch (e) { + /* ignore */ + } + this.currentIntro = null; + this.currentLoop = null; + } + + private stopEngine() { + this.audioCtx?.close(); + this.audioCtx = null; + this.fadeGain = null; + this.masterGain = null; + this.shuttingDown = false; + } + + // Return playback position in seconds relative to intro start (intro=0..introDuration, then loop) + getPosition(): number { + if (!this.audioCtx) return 0; + if (this.introStartTime === undefined) { + // paused, position stays constant + return this.playedDuration ?? 0; + } else { + // make sure playback position stays in bounds: wrap around loop duration + const now = this.audioCtx.currentTime; + let playedDuration = now - this.introStartTime; + if ( + this.introDuration !== undefined && this.loopDuration !== undefined && + playedDuration > this.introDuration + this.loopDuration + ) { + playedDuration = this.introDuration + (playedDuration - this.introDuration) % this.loopDuration; + } + return Math.max(0, now - this.introStartTime); + } + } +} + +const audioEngine = new AudioEngine(); +export default audioEngine; diff --git a/Frontend/src/components/ErrorScreen.vue b/Frontend/src/components/ErrorScreen.vue new file mode 100644 index 0000000..b0a306d --- /dev/null +++ b/Frontend/src/components/ErrorScreen.vue @@ -0,0 +1,20 @@ + + + diff --git a/Frontend/src/components/Footer.vue b/Frontend/src/components/Footer.vue new file mode 100644 index 0000000..cee5da7 --- /dev/null +++ b/Frontend/src/components/Footer.vue @@ -0,0 +1,52 @@ + + + diff --git a/Frontend/src/components/GlobalHeader.vue b/Frontend/src/components/GlobalHeader.vue new file mode 100644 index 0000000..49da20c --- /dev/null +++ b/Frontend/src/components/GlobalHeader.vue @@ -0,0 +1,43 @@ + + + + diff --git a/Frontend/src/components/LoadingScreen.vue b/Frontend/src/components/LoadingScreen.vue new file mode 100644 index 0000000..efce166 --- /dev/null +++ b/Frontend/src/components/LoadingScreen.vue @@ -0,0 +1,92 @@ + + + + diff --git a/Frontend/src/components/ScreenTransition.vue b/Frontend/src/components/ScreenTransition.vue new file mode 100644 index 0000000..5dd8665 --- /dev/null +++ b/Frontend/src/components/ScreenTransition.vue @@ -0,0 +1,39 @@ + + + diff --git a/Frontend/src/components/SearchField.vue b/Frontend/src/components/SearchField.vue new file mode 100644 index 0000000..edd1dc9 --- /dev/null +++ b/Frontend/src/components/SearchField.vue @@ -0,0 +1,56 @@ + + + diff --git a/Frontend/src/components/editor/PreviewScnene.vue b/Frontend/src/components/editor/PreviewScnene.vue new file mode 100644 index 0000000..47fbf98 --- /dev/null +++ b/Frontend/src/components/editor/PreviewScnene.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/Frontend/src/components/editor/TrackInfo.vue b/Frontend/src/components/editor/TrackInfo.vue new file mode 100644 index 0000000..aaefbd6 --- /dev/null +++ b/Frontend/src/components/editor/TrackInfo.vue @@ -0,0 +1,82 @@ + + + + diff --git a/Frontend/src/components/library/Card.vue b/Frontend/src/components/library/Card.vue new file mode 100644 index 0000000..c0747cb --- /dev/null +++ b/Frontend/src/components/library/Card.vue @@ -0,0 +1,188 @@ + + + + diff --git a/Frontend/src/components/library/CardSeparator.vue b/Frontend/src/components/library/CardSeparator.vue new file mode 100644 index 0000000..a8213dd --- /dev/null +++ b/Frontend/src/components/library/CardSeparator.vue @@ -0,0 +1,14 @@ + + + diff --git a/Frontend/src/components/library/ColorSwatch.vue b/Frontend/src/components/library/ColorSwatch.vue new file mode 100644 index 0000000..22d24ac --- /dev/null +++ b/Frontend/src/components/library/ColorSwatch.vue @@ -0,0 +1,13 @@ + + + + diff --git a/Frontend/src/components/library/LibraryContent.vue b/Frontend/src/components/library/LibraryContent.vue new file mode 100644 index 0000000..9acbf80 --- /dev/null +++ b/Frontend/src/components/library/LibraryContent.vue @@ -0,0 +1,92 @@ + + + + diff --git a/Frontend/src/components/library/SectionHeader.vue b/Frontend/src/components/library/SectionHeader.vue new file mode 100644 index 0000000..7baff8e --- /dev/null +++ b/Frontend/src/components/library/SectionHeader.vue @@ -0,0 +1,22 @@ + + + + diff --git a/Frontend/src/components/library/Slider.vue b/Frontend/src/components/library/Slider.vue new file mode 100644 index 0000000..02b9c8f --- /dev/null +++ b/Frontend/src/components/library/Slider.vue @@ -0,0 +1,92 @@ + + + diff --git a/Frontend/src/components/library/ToolBar.module.css b/Frontend/src/components/library/ToolBar.module.css new file mode 100644 index 0000000..a2ec5c8 --- /dev/null +++ b/Frontend/src/components/library/ToolBar.module.css @@ -0,0 +1,80 @@ +@import "tailwindcss" prefix(tw); + +@layer utilities { + .tool-button { + &:not(:disabled):active { + color: white; + /* Note: do not apply transform to the button itself, as it would affect its interaction target. */ + &>*:active { + transform: translateY(1px); + } + } + } +} + +@layer components { + .toolbar-control { + color: #888888; + background: none; + border: none; + padding: 0; + cursor: pointer; + line-height: 0; + + @apply tw:flex-none tw:w-12 tw:h-12 tw:rounded-full; + + @variant hover { + &:not(:disabled) { + @apply tw:text-gray-300; + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &>svg { + fill: currentColor; + @apply tw:w-12 tw:h-12; + filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px); + } + } + + .toolbar-toggle { + } + .toolbar-toggle-checked { + color: white; + + @variant hover { + &:not(:disabled) { + color: white; + } + } + } + + .tool-button-small { + color: #757575; + background-color: #2c2c30; + /* will-change: transform; */ + + @apply tw:flex-none tw:w-4 tw:h-4 tw:rounded-full; + + @variant hover { + &:not(:disabled) { + color: #b9b9ba; + background-color: #303034; + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &>svg { + fill: currentColor; + @apply tw:w-4 tw:h-4; + } + } +} diff --git a/Frontend/src/components/library/ToolButton.vue b/Frontend/src/components/library/ToolButton.vue new file mode 100644 index 0000000..21f2a62 --- /dev/null +++ b/Frontend/src/components/library/ToolButton.vue @@ -0,0 +1,15 @@ + + + diff --git a/Frontend/src/components/library/ToolButtonSmall.vue b/Frontend/src/components/library/ToolButtonSmall.vue new file mode 100644 index 0000000..af55692 --- /dev/null +++ b/Frontend/src/components/library/ToolButtonSmall.vue @@ -0,0 +1,17 @@ + + + diff --git a/Frontend/src/components/library/ToolToggle.vue b/Frontend/src/components/library/ToolToggle.vue new file mode 100644 index 0000000..9a94d52 --- /dev/null +++ b/Frontend/src/components/library/ToolToggle.vue @@ -0,0 +1,17 @@ + + + diff --git a/Frontend/src/components/library/TrackCard.vue b/Frontend/src/components/library/TrackCard.vue new file mode 100644 index 0000000..709376e --- /dev/null +++ b/Frontend/src/components/library/TrackCard.vue @@ -0,0 +1,128 @@ + + + \ No newline at end of file diff --git a/Frontend/src/components/library/TrackCardBadge.vue b/Frontend/src/components/library/TrackCardBadge.vue new file mode 100644 index 0000000..ff42641 --- /dev/null +++ b/Frontend/src/components/library/TrackCardBadge.vue @@ -0,0 +1,15 @@ + + + diff --git a/Frontend/src/components/library/VolumeSlider.vue b/Frontend/src/components/library/VolumeSlider.vue new file mode 100644 index 0000000..bf57547 --- /dev/null +++ b/Frontend/src/components/library/VolumeSlider.vue @@ -0,0 +1,81 @@ + + + diff --git a/Frontend/src/components/library/ZoomSlider.vue b/Frontend/src/components/library/ZoomSlider.vue new file mode 100644 index 0000000..479fbe6 --- /dev/null +++ b/Frontend/src/components/library/ZoomSlider.vue @@ -0,0 +1,95 @@ + + + diff --git a/Frontend/src/components/scrollsync/ScrollSync.vue b/Frontend/src/components/scrollsync/ScrollSync.vue new file mode 100644 index 0000000..c9f5270 --- /dev/null +++ b/Frontend/src/components/scrollsync/ScrollSync.vue @@ -0,0 +1,145 @@ + + + + diff --git a/Frontend/src/components/timeline/MasterVolumeSlider.vue b/Frontend/src/components/timeline/MasterVolumeSlider.vue new file mode 100644 index 0000000..e692074 --- /dev/null +++ b/Frontend/src/components/timeline/MasterVolumeSlider.vue @@ -0,0 +1,30 @@ + + + diff --git a/Frontend/src/components/timeline/Playhead.vue b/Frontend/src/components/timeline/Playhead.vue new file mode 100644 index 0000000..cb2ebc1 --- /dev/null +++ b/Frontend/src/components/timeline/Playhead.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/Frontend/src/components/timeline/Timeline.vue b/Frontend/src/components/timeline/Timeline.vue new file mode 100644 index 0000000..90894fd --- /dev/null +++ b/Frontend/src/components/timeline/Timeline.vue @@ -0,0 +1,338 @@ + + + diff --git a/Frontend/src/components/timeline/TimelinePanel.vue b/Frontend/src/components/timeline/TimelinePanel.vue new file mode 100644 index 0000000..d6d3ff8 --- /dev/null +++ b/Frontend/src/components/timeline/TimelinePanel.vue @@ -0,0 +1,80 @@ + + + diff --git a/Frontend/src/components/timeline/TimelineTrackHeader.vue b/Frontend/src/components/timeline/TimelineTrackHeader.vue new file mode 100644 index 0000000..387acc4 --- /dev/null +++ b/Frontend/src/components/timeline/TimelineTrackHeader.vue @@ -0,0 +1,58 @@ + + + diff --git a/Frontend/src/components/timeline/TimelineTrackView.vue b/Frontend/src/components/timeline/TimelineTrackView.vue new file mode 100644 index 0000000..39fbf5e --- /dev/null +++ b/Frontend/src/components/timeline/TimelineTrackView.vue @@ -0,0 +1,34 @@ + + + diff --git a/Frontend/src/components/timeline/Timestamp.vue b/Frontend/src/components/timeline/Timestamp.vue new file mode 100644 index 0000000..058b853 --- /dev/null +++ b/Frontend/src/components/timeline/Timestamp.vue @@ -0,0 +1,25 @@ + + + + diff --git a/Frontend/src/components/timeline/clip/TimelineClipView.vue b/Frontend/src/components/timeline/clip/TimelineClipView.vue new file mode 100644 index 0000000..fc35bf4 --- /dev/null +++ b/Frontend/src/components/timeline/clip/TimelineClipView.vue @@ -0,0 +1,96 @@ + + + diff --git a/Frontend/src/components/timeline/clip/impl/BottomLine.vue b/Frontend/src/components/timeline/clip/impl/BottomLine.vue new file mode 100644 index 0000000..d3119b6 --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/BottomLine.vue @@ -0,0 +1,4 @@ + diff --git a/Frontend/src/components/timeline/clip/impl/Default.vue b/Frontend/src/components/timeline/clip/impl/Default.vue new file mode 100644 index 0000000..6f285f9 --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/Default.vue @@ -0,0 +1,51 @@ + + + diff --git a/Frontend/src/components/timeline/clip/impl/Empty.vue b/Frontend/src/components/timeline/clip/impl/Empty.vue new file mode 100644 index 0000000..b475948 --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/Empty.vue @@ -0,0 +1,24 @@ + + + diff --git a/Frontend/src/components/timeline/clip/impl/FadeOut.vue b/Frontend/src/components/timeline/clip/impl/FadeOut.vue new file mode 100644 index 0000000..2d0dea1 --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/FadeOut.vue @@ -0,0 +1,42 @@ + + + diff --git a/Frontend/src/components/timeline/clip/impl/Lyrics.vue b/Frontend/src/components/timeline/clip/impl/Lyrics.vue new file mode 100644 index 0000000..01ec6e6 --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/Lyrics.vue @@ -0,0 +1,45 @@ + + + diff --git a/Frontend/src/components/timeline/clip/impl/Palette.vue b/Frontend/src/components/timeline/clip/impl/Palette.vue new file mode 100644 index 0000000..ff8157a --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/Palette.vue @@ -0,0 +1,76 @@ + + + diff --git a/Frontend/src/components/timeline/clip/impl/README.md b/Frontend/src/components/timeline/clip/impl/README.md new file mode 100644 index 0000000..14abd9a --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/README.md @@ -0,0 +1 @@ +Content view components for different TimelineClipView implementations. diff --git a/Frontend/src/components/timeline/clip/impl/index.ts b/Frontend/src/components/timeline/clip/impl/index.ts new file mode 100644 index 0000000..762de0f --- /dev/null +++ b/Frontend/src/components/timeline/clip/impl/index.ts @@ -0,0 +1,10 @@ +/** + * Content view components for different TimelineClipView implementations. + * @module components/timeline/clip/impl + */ + +export { default as Default } from "./Default.vue"; +export { default as Empty } from "./Empty.vue"; +export { default as FadeOut } from "./FadeOut.vue"; +export { default as Lyrics } from "./Lyrics.vue"; +export { default as Palette } from "./Palette.vue"; diff --git a/Frontend/src/components/timeline/clip/index.ts b/Frontend/src/components/timeline/clip/index.ts new file mode 100644 index 0000000..eaf2064 --- /dev/null +++ b/Frontend/src/components/timeline/clip/index.ts @@ -0,0 +1,30 @@ +import type { TimelineClipData, TimelineTrackData } from "@/lib/Timeline"; +import type { Component } from "vue"; +import { Default, FadeOut, Lyrics, Palette } from "./impl"; + +export interface ClipContentViewProps { + track: TimelineTrackData; + clip: TimelineClipData; + width: number; +} + +export type ClipContentViewComponent = Component; + +export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent { + switch (track.contentViewType) { + case "audio": + return Default; + case "event": + return Default; + case "fadeout": + return FadeOut; + case "palette": + return Palette; + case "text": + return Lyrics; + case "curve": + return Default; + default: + return Default; + } +} diff --git a/Frontend/src/components/timeline/header/TickInterval.vue b/Frontend/src/components/timeline/header/TickInterval.vue new file mode 100644 index 0000000..0678a59 --- /dev/null +++ b/Frontend/src/components/timeline/header/TickInterval.vue @@ -0,0 +1,91 @@ + + + diff --git a/Frontend/src/components/timeline/header/TimelineHeader.vue b/Frontend/src/components/timeline/header/TimelineHeader.vue new file mode 100644 index 0000000..db7971e --- /dev/null +++ b/Frontend/src/components/timeline/header/TimelineHeader.vue @@ -0,0 +1,29 @@ + + + diff --git a/Frontend/src/components/timeline/markers/HeaderMarkers.vue b/Frontend/src/components/timeline/markers/HeaderMarkers.vue new file mode 100644 index 0000000..fa5fdf9 --- /dev/null +++ b/Frontend/src/components/timeline/markers/HeaderMarkers.vue @@ -0,0 +1,18 @@ + + + diff --git a/Frontend/src/components/timeline/markers/MarkerBox.vue b/Frontend/src/components/timeline/markers/MarkerBox.vue new file mode 100644 index 0000000..d792804 --- /dev/null +++ b/Frontend/src/components/timeline/markers/MarkerBox.vue @@ -0,0 +1,18 @@ + + + diff --git a/Frontend/src/components/timeline/markers/MarkerLine.vue b/Frontend/src/components/timeline/markers/MarkerLine.vue new file mode 100644 index 0000000..cffbaab --- /dev/null +++ b/Frontend/src/components/timeline/markers/MarkerLine.vue @@ -0,0 +1,18 @@ + + + diff --git a/Frontend/src/components/timeline/markers/TickLine.vue b/Frontend/src/components/timeline/markers/TickLine.vue new file mode 100644 index 0000000..526e62c --- /dev/null +++ b/Frontend/src/components/timeline/markers/TickLine.vue @@ -0,0 +1,17 @@ + + + diff --git a/Frontend/src/components/timeline/markers/TimelineMarkers.vue b/Frontend/src/components/timeline/markers/TimelineMarkers.vue new file mode 100644 index 0000000..0b3cf5d --- /dev/null +++ b/Frontend/src/components/timeline/markers/TimelineMarkers.vue @@ -0,0 +1,32 @@ + + + + diff --git a/Frontend/src/components/timeline/markers/index.ts b/Frontend/src/components/timeline/markers/index.ts new file mode 100644 index 0000000..674ed04 --- /dev/null +++ b/Frontend/src/components/timeline/markers/index.ts @@ -0,0 +1,11 @@ +/** + * Components for rendering markers on a timeline. + * + * Markers are split into two layers: + * - little pointy boxes on the timeline header, just below the playhead, + * - and thin colored vertical lines stretching across the timeline, below the clips. + * + * Markers for beats only have hairline-styled layer, without boxes. They go below other markers with boxes. + */ +export { default as TimelineMarkers } from "./TimelineMarkers.vue"; +export { default as HeaderMarkers } from "./HeaderMarkers.vue"; diff --git a/Frontend/src/easing.ts b/Frontend/src/easing.ts new file mode 100644 index 0000000..aca53e7 --- /dev/null +++ b/Frontend/src/easing.ts @@ -0,0 +1,3 @@ +export function easeInOutQuad(x: number): number { + return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; +} diff --git a/Frontend/src/events.ts b/Frontend/src/events.ts new file mode 100644 index 0000000..003c24d --- /dev/null +++ b/Frontend/src/events.ts @@ -0,0 +1,20 @@ +import mitt, { type Handler } from "mitt"; +import { onBeforeUnmount } from "vue"; + +export type Events = { + scrollToTop: void; +}; + +export const emitter = mitt(); + +export function useEvent( + type: Key, + handler: Handler, +) { + const { on, off } = emitter; + + on(type, handler); + onBeforeUnmount(() => { + off(type, handler); + }); +} diff --git a/Frontend/src/lib/AudioTrack.ts b/Frontend/src/lib/AudioTrack.ts new file mode 100644 index 0000000..9532006 --- /dev/null +++ b/Frontend/src/lib/AudioTrack.ts @@ -0,0 +1,175 @@ +export const LANGUAGES = [ + "English", + "Russian", + "Korean", + "Japanese", + "Hindi", +] as const; + +export declare type Language = typeof LANGUAGES[number]; + +export declare type ColorString = string; + +export declare type AudioType = + | "mpeg" + | "wav" + | "ogg"; + +export function ext(audioType: AudioType): string { + switch (audioType) { + case "mpeg": + return "mp3"; + case "wav": + return "wav"; + case "ogg": + return "ogg"; + default: + return ""; + } +} + +export interface AudioTrack { + // Propreties from JSON + Name: string; + IsExplicit: boolean; + Language: Language; + WindUpTimer: number; + Bpm: number; + Beats: number; + LoopOffset: number; + Ext: string; + FileDurationIntro: number; + FileDurationLoop: number; + FileNameIntro: string; + FileNameLoop: string; + BeatsOffset: number; + + FadeOutBeat: number; + FadeOutDuration: number; + ColorTransitionIn: number; + ColorTransitionOut: number; + ColorTransitionEasing: string; + + FlickerLightsTimeSeries: number[]; + Lyrics: TimeSeries; + DrunknessLoopOffsetTimeSeries: TimeSeries; + CondensationLoopOffsetTimeSeries: TimeSeries; + + Palette: ColorString[]; + GameOverText: string | null; + + // Added from Codenames.json + Artist: string; + Song: string; + + // Properties added by the TrackStore + loadedIntro: AudioBuffer | null; + loadedLoop: AudioBuffer | null; +} + +export function dummyAudioTrackForTesting(): AudioTrack { + return { + Name: "Test Name", + IsExplicit: false, + Language: "English", + WindUpTimer: 35.0, + Bpm: 120, + Beats: 12, + LoopOffset: 8, + Ext: "ogg", + FileDurationIntro: 35 + 16 + 2, + FileDurationLoop: 24.0, + FileNameIntro: "TestIntro.ogg", + FileNameLoop: "TestLoop.ogg", + BeatsOffset: 0, + + FadeOutBeat: NaN, + FadeOutDuration: 0, + ColorTransitionIn: 0.25, + ColorTransitionOut: 0.25, + ColorTransitionEasing: "OutExpo", + + FlickerLightsTimeSeries: [], + Lyrics: [], + DrunknessLoopOffsetTimeSeries: [], + CondensationLoopOffsetTimeSeries: [], + + Palette: ["#000000", "#FFFFFF"], + GameOverText: "", + + Artist: "Test Artist", + Song: "Test Song", + + loadedIntro: null, + loadedLoop: null, + }; +} + +export type TimeSeries = [number, T][]; + +export function timeSeriesIsEmpty(timeSeries: TimeSeries): boolean { + return timeSeries.length !== 0; +} + +export interface Codenames { + [key: string]: { + Artist: string; + Song: string; + }; +} + +export function formatTime(time: number, precision: number = 3): string { + const isNegative = time < 0; + const isNegativeString = isNegative ? "-" : ""; + if (isNegative) { + time = -time; + } + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + const milliseconds = Math.floor((time * 1000) % 1000); + const secondsString = seconds.toString().padStart(2, "0"); + const millisecondsString = milliseconds.toString().padStart(precision, "0"); + return `${isNegativeString}${minutes}:${secondsString}.${millisecondsString}`; +} + +export function formatBeats(beats: number, precision: number = 3): string { + const isNegative = beats < 0; + const isNegativeString = isNegative ? "-" : ""; + if (isNegative) { + beats = -beats; + } + const integer = Math.floor(beats); + const fractional = Math.floor((beats % 1) * 1000); + const integerString = integer.toString().padEnd(2, "0"); + const fractionalString = fractional.toString().padStart(precision, "0"); + return `${isNegativeString}${integerString}.${fractionalString}`; +} + +export function secondsToBeats(track: AudioTrack, seconds: number): number { + const percent = seconds / track.FileDurationLoop; + return percent * track.Beats; +} + +export function beatsToSeconds(track: AudioTrack, beats: number): number { + const percent = beats / track.Beats; + return percent * track.FileDurationLoop; +} + +/** Duration of LoopOffset beats converted to seconds. */ +export function loopOffsetSeconds(track: AudioTrack): number { + return beatsToSeconds(track, track.LoopOffset); +} + +/** Duration of Wind-up Timer plus Loop Offset combined and converted to seconds. */ +export function introWithLoopOffsetDurationSeconds(track: AudioTrack): number { + const { WindUpTimer } = track; + + return WindUpTimer + loopOffsetSeconds(track); +} + +/** Duration of Wind-up Timer plus Loop Offset plus one full loop combined and converted to seconds. */ +export function totalDurationSeconds(track: AudioTrack) { + const { FileDurationLoop } = track; + + return introWithLoopOffsetDurationSeconds(track) + FileDurationLoop; +} diff --git a/Frontend/src/lib/Timeline.ts b/Frontend/src/lib/Timeline.ts new file mode 100644 index 0000000..774f761 --- /dev/null +++ b/Frontend/src/lib/Timeline.ts @@ -0,0 +1,316 @@ +import { + type AudioTrack, + beatsToSeconds, + type ColorString, + loopOffsetSeconds, +} from "@/lib/AudioTrack"; +import * as namedColors from "@/lib/colors/named-vars"; +import { green } from "./colors/named-vars"; +import { iterWindowPairs } from "./iter"; + +/** + * Reference point for all clips on the timeline track. + * + * - "absolute" means relative to start of the intro, measured in seconds. + * - "wind-up" is relative to the WindUpTimer, when Jester pops up, measured in beats. + * - "loop" is relative to the WindUpTimer plus loop offset, measured in beats. + */ +export type Reference = "absolute" | "wind-up" | "loop"; + +/** Clip views support dynamic tailored content view for each track type. */ +export type ContentViewType = + | "audio" + /** One-shot event at clipIn */ + | "event" + | "fadeout" + | "palette" + | "text" + /** Interpolated line between points in time series. */ + | "curve" + +export interface MuzikaGromcheTimelineTracksMap { + intro: TimelineTrackData; + loop: TimelineTrackData; + flickering: TimelineTrackData; + fadeOut: TimelineTrackData; + palette: TimelineTrackData; + lyrics: TimelineTrackData; + drunkness: TimelineTrackData; + condensation: TimelineTrackData; +} + +export function timelineTracksArray( + self: MuzikaGromcheTimelineTracksMap, +): TimelineTrackData[] { + return [ + self.intro, + self.loop, + self.flickering, + self.fadeOut, + self.palette, + self.lyrics, + self.drunkness, + self.condensation, + ]; +} + +export function emptyTimelineTracksMap(): MuzikaGromcheTimelineTracksMap { + return { + intro: { + name: "Intro", + color: namedColors.lime, + reference: "absolute", + clips: [], + contentViewType: "audio", + }, + loop: { + name: "Loop", + color: namedColors.blue, + reference: "absolute", + clips: [], + contentViewType: "audio", + }, + flickering: { + name: "Flickering", + color: namedColors.violet, + reference: "loop", + clips: [], + contentViewType: "event", + }, + fadeOut: { + name: "Fade out", + color: namedColors.chocolate, + reference: "loop", + clips: [], + contentViewType: "fadeout", + }, + palette: { + name: "Palette", + color: namedColors.pink, + reference: "wind-up", + clips: [], + contentViewType: "palette", + }, + lyrics: { + name: "Lyrics", + color: namedColors.tan, + reference: "loop", + clips: [], + contentViewType: "text", + }, + drunkness: { + name: "Drunkness", + color: namedColors.orange, + reference: "loop", + clips: [], + contentViewType: "curve", + }, + condensation: { + name: "Condensation", + color: namedColors.yellow, + reference: "loop", + clips: [], + contentViewType: "curve", + }, + }; +} + +export function generateClips( + track: AudioTrack, +): MuzikaGromcheTimelineTracksMap { + const tracks = emptyTimelineTracksMap(); + + tracks.intro.clips.push({ clipIn: 0, duration: track.FileDurationIntro }); + { + let clipIn = track.FileDurationIntro; + tracks.loop.clips.push( + { clipIn, duration: track.FileDurationLoop }, + ); + for (let i = 1; i < 10; i++) { + let clipIn2 = clipIn + track.FileDurationLoop * i; + tracks.loop.clips.push( + { clipIn: clipIn2, duration: track.FileDurationLoop, autorepeat: true }, + ); + } + } + tracks.fadeOut.clips.push( + { clipIn: track.FadeOutBeat, duration: track.FadeOutDuration }, + ); + // TODO: palette, lyrics, both VFX + { + for (const time of track.FlickerLightsTimeSeries) { + tracks.flickering.clips.push( + { clipIn: time, duration: 1 }, + ); + } + } + { + // TODO: offset by? + // track.ColorTransitionIn + for (let i = 0; i < track.Palette.length; i++) { + const color = track.Palette[i]; + tracks.palette.clips.push( + { clipIn: i, duration: 1, color, name: color }, + ); + } + } + { + for (const [[time, text], next] of iterWindowPairs(track.Lyrics)) { + let duration = 4; + if (next !== undefined) { + // make sure adjacent clips don't overlap + const [nextTime, _nextText] = next; + duration = Math.min(duration, nextTime - time); + } + tracks.lyrics.clips.push( + { clipIn: time, duration, name: text }, + ); + } + } + return tracks; +} + +export interface TimelineTrackData { + name: string; + color?: string; + reference: Reference; + clips: TimelineClipData[]; + contentViewType?: ContentViewType, +} + +export interface TimelineClipData { + name?: string; + color?: string; + clipIn: number; + duration: number; + autorepeat?: boolean; +} + +export function timelineClipAutorepeat(self: TimelineClipData): boolean { + return self.autorepeat ?? false; +} + +export function timelineClipOut(self: TimelineClipData) { + return self.clipIn + self.duration; +} + +export function timelineClipColor( + track: TimelineTrackData, + clip: TimelineClipData, +): ColorString { + return clip.color ?? track.color ?? green; +} + +export function timelineClipLabel( + track: TimelineTrackData, + clip: TimelineClipData, +): ColorString { + return clip.name ?? track.name; +} + +export interface TimelineMarkerData { + name: string; + color: string; + reference: Reference; + markerIn: number; +} + +export function generateMarkers(track: AudioTrack): TimelineMarkerData[] { + const markers = []; + + if (track.LoopOffset === 0) { + markers.push({ + name: "Wind-up Timer & Loop Offset", + color: namedColors.purple, + reference: "wind-up", + markerIn: 0, + }); + } else { + markers.push({ + name: "Wind-up Timer", + color: namedColors.purple, + reference: "wind-up", + markerIn: 0, + }); + markers.push({ + name: "Loop Offset", + color: namedColors.violet, + reference: "loop", + markerIn: 0, + }); + } + markers.push({ + name: "End of Loop", + color: namedColors.purple, + reference: "loop", + markerIn: track.Beats, + }); + + // TODO: i from absolute zero, not wind-up zero + for (let i = 1; i < track.Beats; i++) { + if (i % 4 === 0) { + // marker on strong beat + markers.push({ + name: "Bar", + color: namedColors.blue, + reference: "loop", + markerIn: i, + }); + } else { + // regular marker on other beats + markers.push({ + name: "Beat", + color: namedColors.teal, + reference: "loop", + markerIn: i, + }); + } + } + + return []; +} + +export function toAbsoluteDuration( + track: AudioTrack | null, + reference: Reference, + time: number, +): number { + if (track === null) { + return 0; + } + switch (reference) { + default: + case "absolute": + return time; + case "wind-up": + case "loop": + return beatsToSeconds(track, time); + } +} + +export function toAbsoluteTime( + track: AudioTrack | null, + reference: Reference, + time: number, +): number { + if (track === null) { + return 0; + } + switch (reference) { + default: + case "absolute": + return time; + case "wind-up": + return beatsToSeconds(track, time) + track.WindUpTimer; + case "loop": + return beatsToSeconds(track, time) + track.WindUpTimer + + loopOffsetSeconds(track); + } +} + +export function markerToAbsoluteTime( + track: AudioTrack, + marker: TimelineMarkerData, +): number { + return toAbsoluteTime(track, marker.reference, marker.markerIn); +} diff --git a/Frontend/src/lib/TinelineTicks.ts b/Frontend/src/lib/TinelineTicks.ts new file mode 100644 index 0000000..c4f9d83 --- /dev/null +++ b/Frontend/src/lib/TinelineTicks.ts @@ -0,0 +1,146 @@ +import type { AnyTime, Beats, Px, Seconds } from "@/lib/units"; +import { + computed, + type ComputedRef, + type MaybeRefOrGetter, + shallowReadonly, + type ShallowRef, + shallowRef, + toValue, + watch, +} from "vue"; + +const TICK_INTERVAL_PX_THRESHOLD = 150; + +/** + * Find such time interval that converted to pixels it won't exceed this threshold. + * Start with large intervals, and progressively subdivide it until a suitably small interval is found. + * @param width timeline width (including empty space) in pixels + * @param duration timeline visual duration (including empty space) in seconds + * @returns Visually optimal interval for ticks, in seconds. + */ +export function findOptimalTickInterval( + width: Px, + duration: Seconds, +): Seconds { + const pxPerSec = Number.isFinite(duration) && duration > 0 + ? width / duration + : NaN; + + // If we can't compute a sensible pixels-per-second, fall back to the smallest interval. + if (!Number.isFinite(pxPerSec)) return 1; + + const seconds = TICK_INTERVAL_PX_THRESHOLD / pxPerSec; + if (seconds >= 2) { + return Math.floor(seconds / 2) * 2; + } else { + return 1; + } +} + +export function useOptimalTickInterval( + width: MaybeRefOrGetter, + duration: MaybeRefOrGetter, +): ComputedRef { + return computed(() => + findOptimalTickInterval(toValue(width), toValue(duration)) + ); +} + +/** + * Find such beats interval that converted to pixels it won't exceed this threshold. + * @param width timeline width (including empty space) in pixels + * @param duration timeline visual duration (including empty space) in beats + * @returns Visually optimal interval for ticks, in beats. + */ +export function findOptimalBeatTickInterval( + width: Px, + duration: Beats, +): Beats { + const pxPerBeat = Number.isFinite(duration) && duration > 0 + ? width / duration + : NaN; + + // If we can't compute a sensible pixels-per-beat, fall back to the smallest interval. + if (!Number.isFinite(pxPerBeat)) return 1; + + const beats = TICK_INTERVAL_PX_THRESHOLD / pxPerBeat; + if (beats >= 4) { + return Math.floor(beats / 4) * 4; + } else { + return 1; + } +} + +export function useOptimalBeatTickInterval( + width: MaybeRefOrGetter, + duration: MaybeRefOrGetter, +): ComputedRef { + return computed(() => + findOptimalTickInterval(toValue(width), toValue(duration)) + ); +} + +export interface TicksBounds { + tickIn: T; + tickOut: T; +} + +/** + * Find closest bounds for ticks divisible by `interval` such that they cover the whole viewport. + * TickIn is the largest value <= viewportIn that is a multiple of `interval`. + * TickOut is the smallest value >= viewportOut that is a multiple of `interval`. + * + * Notes: + * - `interval` is expected to be a positive finite number. If it's invalid, the original viewport bounds are returned. + * - Works with fractional intervals and negative times. + * + * @param interval tick spacing (seconds or beats) + * @param viewportIn left/earlier bound of the viewport (seconds or beats) + * @param viewportOut right/later bound of the viewport (seconds or beats) + * @returns object with { tickIn, tickOut } + */ +export function findTicksBounds( + interval: T, + viewportIn: T, + viewportOut: T, +): TicksBounds { + if (!Number.isFinite(interval) || interval <= 0) { + return { tickIn: viewportIn, tickOut: viewportOut }; + } + + // Normalize -0 to 0 for cleanliness + const normalize = (v: T) => (Object.is(v, -0) ? 0 as T : v); + + const tickIn = normalize(Math.floor(viewportIn / interval) * interval as T); + const tickOut = normalize(Math.ceil(viewportOut / interval) * interval as T); + + return { tickIn, tickOut }; +} + +export function useTicksBounds( + interval: MaybeRefOrGetter, + viewportIn: MaybeRefOrGetter, + viewportOut: MaybeRefOrGetter, +): Readonly>> { + // only trigger when really needed. + const ticksBounds = shallowRef>({ + tickIn: toValue(viewportIn), + tickOut: toValue(viewportOut), + }); + watch([interval, viewportIn, viewportOut], () => { + const bounds = findTicksBounds( + toValue(interval), + toValue(viewportIn), + toValue(viewportOut), + ); + + if ( + bounds.tickIn !== ticksBounds.value.tickIn || + bounds.tickOut !== ticksBounds.value.tickOut + ) { + ticksBounds.value = bounds; + } + }, { immediate: true }); + return shallowReadonly(ticksBounds); +} diff --git a/Frontend/src/lib/colors/array-hex.ts b/Frontend/src/lib/colors/array-hex.ts new file mode 100644 index 0000000..e0295e8 --- /dev/null +++ b/Frontend/src/lib/colors/array-hex.ts @@ -0,0 +1,18 @@ +export default [ + "#eb6e01", + "#ffa833", + "#d4ad1f", + "#9fc615", + "#5f9921", + "#448f65", + "#019899", + "#005278", + "#4376a1", + "#9972a0", + "#d0568d", + "#e98cb5", + "#b9af97", + "#c4a07c", + "#996601", + "#8c5a3f", +] as const; diff --git a/Frontend/src/lib/colors/array-vars.ts b/Frontend/src/lib/colors/array-vars.ts new file mode 100644 index 0000000..9bc174b --- /dev/null +++ b/Frontend/src/lib/colors/array-vars.ts @@ -0,0 +1,18 @@ +export default [ + "var(--timeline-clip-color-orange)", + "var(--timeline-clip-color-apricot)", + "var(--timeline-clip-color-yellow)", + "var(--timeline-clip-color-lime)", + "var(--timeline-clip-color-olive)", + "var(--timeline-clip-color-green)", + "var(--timeline-clip-color-teal)", + "var(--timeline-clip-color-navy)", + "var(--timeline-clip-color-blue)", + "var(--timeline-clip-color-purple)", + "var(--timeline-clip-color-violet)", + "var(--timeline-clip-color-pink)", + "var(--timeline-clip-color-tan)", + "var(--timeline-clip-color-beige)", + "var(--timeline-clip-color-brown)", + "var(--timeline-clip-color-chocolate)", +] as const; diff --git a/Frontend/src/lib/colors/named-hex.ts b/Frontend/src/lib/colors/named-hex.ts new file mode 100644 index 0000000..45065e6 --- /dev/null +++ b/Frontend/src/lib/colors/named-hex.ts @@ -0,0 +1,16 @@ +export const orange = "#eb6e01"; +export const apricot = "#ffa833"; +export const yellow = "#d4ad1f"; +export const lime = "#9fc615"; +export const olive = "#5f9921"; +export const green = "#448f65"; +export const teal = "#019899"; +export const navy = "#005278"; +export const blue = "#4376a1"; +export const purple = "#9972a0"; +export const violet = "#d0568d"; +export const pink = "#e98cb5"; +export const tan = "#b9af97"; +export const beige = "#c4a07c"; +export const brown = "#996601"; +export const chocolate = "#8c5a3f"; diff --git a/Frontend/src/lib/colors/named-vars.ts b/Frontend/src/lib/colors/named-vars.ts new file mode 100644 index 0000000..4a83c71 --- /dev/null +++ b/Frontend/src/lib/colors/named-vars.ts @@ -0,0 +1,16 @@ +export const orange = "var(--timeline-clip-color-orange)"; +export const apricot = "var(--timeline-clip-color-apricot)"; +export const yellow = "var(--timeline-clip-color-yellow)"; +export const lime = "var(--timeline-clip-color-lime)"; +export const olive = "var(--timeline-clip-color-olive)"; +export const green = "var(--timeline-clip-color-green)"; +export const teal = "var(--timeline-clip-color-teal)"; +export const navy = "var(--timeline-clip-color-navy)"; +export const blue = "var(--timeline-clip-color-blue)"; +export const purple = "var(--timeline-clip-color-purple)"; +export const violet = "var(--timeline-clip-color-violet)"; +export const pink = "var(--timeline-clip-color-pink)"; +export const tan = "var(--timeline-clip-color-tan)"; +export const beige = "var(--timeline-clip-color-beige)"; +export const brown = "var(--timeline-clip-color-brown)"; +export const chocolate = "var(--timeline-clip-color-chocolate)"; diff --git a/Frontend/src/lib/iter.ts b/Frontend/src/lib/iter.ts new file mode 100644 index 0000000..a6b88a3 --- /dev/null +++ b/Frontend/src/lib/iter.ts @@ -0,0 +1,47 @@ +export interface FirstLast { + isFirst: boolean, + isLast: boolean, + value: T, +} + +export function *iterFirstLast(it: T[]): Iterable> { + for (let i = 0; i < it.length; i++) { + yield { + isFirst: i === 0, + isLast: i === it.length - 1, + value: it[i]!, + }; + } +} + +/** Iterate over current & next pairs, with next item being undefined at the end of input array. */ +export function *iterWindowPairs(it: T[]): Iterable<[T, T?]> { + for (let i = 0; i < it.length; i++) { + yield [it[i]!, it[i + 1]]; + } +} + +/** + * Create an inclusive range of numbers from min to max using the given step. + * Step must be non-zero; supports positive or negative steps. + */ +export function rangeInclusive(min: number, max: number, step: number): number[] { + if (step === 0) throw new RangeError("step must not be 0"); + const array: number[] = []; + const forward = min <= max; + + if (forward && step < 0) return array; + if (!forward && step > 0) return array; + + if (forward) { + for (let v = min; v <= max; v += step) { + array.push(v); + } + } else { + for (let v = min; v >= max; v += step) { + array.push(v); + } + } + + return array; +} \ No newline at end of file diff --git a/Frontend/src/lib/math.test.ts b/Frontend/src/lib/math.test.ts new file mode 100644 index 0000000..dcd171b --- /dev/null +++ b/Frontend/src/lib/math.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest' +import { modRange } from './math.js' + +describe('modRange', () => { + test('1 in range 0..2 is still 1', () => { + expect(modRange(1, 0, 2)).toBe(1); + }); + + test('lower bound is preserved', () => { + expect(modRange(1, 1, 2)).toBe(1); + }); + + test('higher bound is wrapped', () => { + expect(modRange(2, 1, 2)).toBe(1); + }); + + test('bad bounds', () => { + expect(() => modRange(0, 9, 1)).toThrow('modRange: min must be less than max (got: min=9, max=1)'); + }); +}); diff --git a/Frontend/src/lib/math.ts b/Frontend/src/lib/math.ts new file mode 100644 index 0000000..b7f9252 --- /dev/null +++ b/Frontend/src/lib/math.ts @@ -0,0 +1,16 @@ +/** + * Wraps any number @n greater or equal to @max into the range @min .. @max using modulo (%) operator. + * @param {Number} n The number to wrap + * @param {Number} min Lower bound of the range + * @param {Number} max Higher bound of the range + * @return {Number} @n if @n is less then + */ +export function modRange(n: number, min: number, max: number): number { + if (min > max) { + throw new Error(`${modRange.name}: min must be less than max (got: min=${min}, max=${max})`); + } + if (n >= max) { + n = min + (n - min) % (max - min); + } + return n; +} diff --git a/Frontend/src/lib/onInputKeyStroke/index.browser.test.ts b/Frontend/src/lib/onInputKeyStroke/index.browser.test.ts new file mode 100644 index 0000000..f28e71a --- /dev/null +++ b/Frontend/src/lib/onInputKeyStroke/index.browser.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { onInputKeyStroke } from "@/lib/onInputKeyStroke"; + +describe("onInputKeyStroke", () => { + it("ignores printable key strokes (Shift+A) when an input is focused", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + const handler = vi.fn(); + const stop = onInputKeyStroke( + (e) => e.shiftKey && (e.key === "A" || e.key === "a"), + handler, + ); + + // dispatch a Shift+A keydown (printable) + const ev = new KeyboardEvent("keydown", { + key: "A", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(ev); + + expect(handler).not.toHaveBeenCalled(); + + input.blur(); + // dispatch again, this time input field isn't focused + const ev2 = new KeyboardEvent("keydown", { + key: "A", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(ev2); + + expect(handler).toHaveBeenCalled(); + + stop(); + input.remove(); + }); + + it("handles non-printable key strokes (Ctrl+Shift+U) even when input is focused", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + const handler = vi.fn(); + const stop = onInputKeyStroke( + (e) => e.ctrlKey && e.shiftKey && (e.key === "U" || e.key === "u"), + handler, + ); + + // dispatch Ctrl+Shift+U (non-printable because ctrlKey is true) + const ev = new KeyboardEvent("keydown", { + key: "U", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(ev); + + expect(handler).toHaveBeenCalled(); + + stop(); + input.remove(); + }); +}); diff --git a/Frontend/src/lib/onInputKeyStroke/index.ts b/Frontend/src/lib/onInputKeyStroke/index.ts new file mode 100644 index 0000000..70cc530 --- /dev/null +++ b/Frontend/src/lib/onInputKeyStroke/index.ts @@ -0,0 +1,93 @@ +import { + type KeyPredicate, + onKeyStroke, + type OnKeyStrokeOptions, +} from "@vueuse/core"; + +export const DATA_DISABLE_SHORTCUTS = "data-disable-shortcuts"; +const DATA_DISABLE_SHORTCUTS_SELECTOR = `[${DATA_DISABLE_SHORTCUTS}]`; + +const textLikeTypes = new Set([ + "text", + "search", + "email", + "url", + "tel", + "password", + "number", + "datetime-local", + "date", + "time", + "month", + "week", +]); + +function isEditableElement(el?: Element | null): boolean { + if (!el) return false; + const elm = el as HTMLElement; + const tag = elm.tagName; + if (elm.isContentEditable) return true; + // Treat TEXTAREA as editable + if (tag === "TEXTAREA") return true; + // For INPUT, only consider text-like input types as editable. This + // excludes sliders, checkboxes, radio buttons, buttons, file inputs, etc. + if (tag === "INPUT") { + const input = elm as HTMLInputElement; + // If no type attribute is present it defaults to 'text' + const type = (input.type || "text").toLowerCase(); + if (textLikeTypes.has(type)) return true; + } + // ARIA text-like roles + if ( + elm.getAttribute && + (elm.getAttribute("role") === "searchbox" || + elm.getAttribute("role") === "textbox") + ) { + return true; + } + // allow opting out by setting this attribute on a container + if (elm.closest && !!elm.closest(DATA_DISABLE_SHORTCUTS_SELECTOR)) { + return true; + } + return false; +} + +/** + * Wrapper around `onKeyStroke` that ignores key strokes when focus is in an + * editable element (input/textarea/contenteditable) or when IME composition + * is active. Signature mirrors `onKeyStroke(predicate, handler, options?)`. + */ +export function onInputKeyStroke( + predicate: KeyPredicate, + handler: (event: KeyboardEvent) => void, + options: OnKeyStrokeOptions = { target: window, dedupe: true }, +) { + return onKeyStroke( + (e: KeyboardEvent) => { + // first, respect the user's predicate + if (!predicate(e)) return false; + + // ignore during IME composition + if (e.isComposing) { + return false; + } + + // if focus is in editable element, do not run shortcut + const active = document.activeElement as Element | null; + if (isEditableElement(active)) { + // allow non-printable shortcuts (Ctrl/Cmd/Alt combos) even when an + // input is focused. Printable characters (single-char keys without + // modifiers) should be ignored so typing isn't interrupted. + const isPrintable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && + !e.altKey; + if (isPrintable) return false; + } + + return true; + }, + handler, + options, + ); +} + +export default onInputKeyStroke; diff --git a/Frontend/src/lib/sleep.ts b/Frontend/src/lib/sleep.ts new file mode 100644 index 0000000..bf89efe --- /dev/null +++ b/Frontend/src/lib/sleep.ts @@ -0,0 +1,4 @@ +/** Async sleep promise, useful for debugging fast-paced transitions. */ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/Frontend/src/lib/units.ts b/Frontend/src/lib/units.ts new file mode 100644 index 0000000..e89819c --- /dev/null +++ b/Frontend/src/lib/units.ts @@ -0,0 +1,9 @@ +export type Seconds = number; +export type Beats = number; +export type AnyTime = Seconds | Beats; + +export type Px = number; +export type PxString = `${Px}px`; + +export type ZoomRaw = number; +export type ZoomDiscrete = number; diff --git a/Frontend/src/lib/useOptionalWidgetState.ts b/Frontend/src/lib/useOptionalWidgetState.ts new file mode 100644 index 0000000..e45524f --- /dev/null +++ b/Frontend/src/lib/useOptionalWidgetState.ts @@ -0,0 +1,47 @@ +import type { Px } from "@/lib/units"; +import { computed, type ComputedRef, type MaybeRef, type Ref, toRef } from "vue"; +import { type UseWritablePx, useWritablePx } from "./usePx"; + +export interface UseOptionalWidgetStateOptions { + visible?: MaybeRef; + showString?: string; + hideString?: string; + width?: MaybeRef; + height?: MaybeRef; +} + +export interface UseOptionalWidgetStateReturn { + visible: Ref; + toggle: () => void; + toggleActionString: ComputedRef; + width: UseWritablePx; + height: UseWritablePx; +} + +export function useOptionalWidgetState( + options: UseOptionalWidgetStateOptions = {}, +): UseOptionalWidgetStateReturn { + const { + visible: initialVisible = false, + showString = "Show", + hideString = "Hide", + width: initialWidth = 0, + height: initialHeight = 0, + } = options; + + const visible = toRef(initialVisible); + function toggle() { + visible.value = !visible.value; + } + const toggleActionString = computed(() => visible.value ? hideString : showString); + const width = useWritablePx(initialWidth); + const height = useWritablePx(initialHeight); + + return { + visible, + toggle, + toggleActionString, + width, + height, + }; +} diff --git a/Frontend/src/lib/usePx/index.test.ts b/Frontend/src/lib/usePx/index.test.ts new file mode 100644 index 0000000..c176c3a --- /dev/null +++ b/Frontend/src/lib/usePx/index.test.ts @@ -0,0 +1,81 @@ +import { toReactive } from "@vueuse/core"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { computed, ref, shallowRef, toRefs } from "vue"; +import { toPx, usePx, useWritablePx, type UseWritablePx } from "."; + +describe("usePx", () => { + test("toPx should work", () => { + // baseline + expect(toPx(42)).toBe("42px"); + // negative + expect(toPx(-5)).toBe("-5px"); + // Vue reactivity + expect(toPx(ref(10))).toBe("10px"); + }); + test("baseline", () => { + const px = usePx(42); + expect(px.value.number).toBe(42); + expect(px.value.string).toBe("42px"); + }); + test("reactivity", () => { + const source = shallowRef(1); + const px = usePx(source); + expect(px.value.number).toBe(1); + expect(px.value.string).toBe("1px"); + source.value = 2; + expect(px.value.number).toBe(2); + expect(px.value.string).toBe("2px"); + }); + test("destructuring toReactive", () => { + const source = shallowRef(1); + // toReactive + const { number, string } = toReactive(usePx(source)); + expect(number).toBe(1); + source.value = 2; + // meh + expect(number).toBe(1); + }); + test("destructuring toRefs", () => { + const source = shallowRef(1); + // toRefs, doesn't make sense + const { number, string } = toRefs(usePx(source).value); + expect(number.value).toBe(1); + source.value = 2; + // meh + expect(number.value).toBe(1); + }); + test("destructuring done right", () => { + const source = shallowRef(1); + const px = usePx(source); + const number = computed(() => px.value.number); + expect(number.value).toBe(1); + source.value = 2; + expect(number.value).toBe(2); + }); +}); + +describe("useWritablePx", () => { + test("type check", () => { + expectTypeOf(useWritablePx(0)); + }); + test("should work by value", () => { + const pxByValue = useWritablePx(42); + expect(pxByValue.number.value).toBe(42); + expect(pxByValue.string.value).toBe("42px"); + + }); + test("should work by ref", () => { + const myRef = shallowRef(42); + const pxByRef = useWritablePx(myRef); + expect(pxByRef.number.value).toBe(42); + expect(pxByRef.string.value).toBe("42px"); + }); + test("destructuring", () => { + const { number, string } = useWritablePx(1); + expect(number.value).toBe(1); + expect(string.value).toBe("1px"); + number.value = 2; + expect(number.value).toBe(2); + expect(string.value).toBe("2px"); + }); +}); diff --git a/Frontend/src/lib/usePx/index.ts b/Frontend/src/lib/usePx/index.ts new file mode 100644 index 0000000..2721aab --- /dev/null +++ b/Frontend/src/lib/usePx/index.ts @@ -0,0 +1,46 @@ +import type { Px, PxString } from "@/lib/units"; +import type { + ComputedRef, + MaybeRef, + MaybeRefOrGetter, + Ref +} from "vue"; +import { computed, toRef, toValue } from "vue"; + +function toPxValue(value: Px): PxString { + return `${value}px`; +} + +export function toPx(value: MaybeRefOrGetter): PxString { + return toPxValue(toValue(value)); +} + +export interface UsePx { + number: Px; + string: PxString; +} + +export function usePx(value: MaybeRefOrGetter): ComputedRef { + return computed(() => { + const number = toValue(value); + const string = toPxValue(number); + return { + number, + string, + }; + }); +} + +export interface UseWritablePx { + number: Ref; + string: ComputedRef; +} + +export function useWritablePx(value: MaybeRef): UseWritablePx { + const number = toRef(value); + const string = computed(() => toPxValue(number.value)); + return { + number, + string, + }; +} diff --git a/Frontend/src/lib/useTimelineTicks.ts b/Frontend/src/lib/useTimelineTicks.ts new file mode 100644 index 0000000..6406abd --- /dev/null +++ b/Frontend/src/lib/useTimelineTicks.ts @@ -0,0 +1,108 @@ +import { formatTime } from "@/lib/AudioTrack"; +import { rangeInclusive } from "@/lib/iter"; +import { + type AnyTime, + type Beats, + type Pixels, + type Seconds, + useOptimalBeatTickInterval, + useOptimalTickInterval, + useTicksBounds, +} from "@/lib/TinelineTicks"; +import { usePx } from "@/lib/vue"; +import { useTimelineStore } from "@/store/TimelineStore"; +import { storeToRefs } from "pinia"; +import { + computed, + type ComputedRef, + type MaybeRefOrGetter, + toValue, +} from "vue"; + +export interface Ticks { + tickIn: ComputedRef; + tickOut: ComputedRef; + interval: ComputedRef; + /** An inclusive range from tickIn to tickOut, with `interval` step. */ + ticks: ComputedRef; + width: ComputedRef; + widthPx: ComputedRef; + left: (tickIn: T) => ComputedRef; + label: (tickIn: T) => ComputedRef; +} + +export function useTimelineTicks( + viewportIn: MaybeRefOrGetter, + viewportOut: MaybeRefOrGetter, + interval: ComputedRef, + intervalToPixels: (interval: T) => Pixels, + positionToPixels: (position: T) => Pixels, + positionToLabel: (position: T) => string, +): Ticks { + const ticksBounds = useTicksBounds(interval, viewportIn, viewportOut); + + const tickIn = computed(() => ticksBounds.value.tickIn); + const tickOut = computed(() => ticksBounds.value.tickOut); + const ticks = computed(() => + rangeInclusive(tickIn.value, tickOut.value, toValue(interval)) as T[] + ); + const width = computed(() => intervalToPixels(toValue(interval))); + const widthPx = usePx(width); + + return { + tickIn, + tickOut, + interval, + ticks, + width, + widthPx, + left: (tickIn) => computed(() => positionToPixels(tickIn)), + label: (tickIn) => computed(() => positionToLabel(tickIn)), + }; +} + +// TODO: cache / singletone / store? + +export function useTimelineTicksSeconds(): Ticks { + const timeline = useTimelineStore(); + const { + contentWidthIncludingEmptySpace, + durationIncludingEmptySpace, + viewportInSeconds, + viewportOutSeconds, + } = storeToRefs(timeline); + + return useTimelineTicks( + viewportInSeconds, + viewportOutSeconds, + useOptimalTickInterval( + contentWidthIncludingEmptySpace, + durationIncludingEmptySpace, + ), + (interval) => timeline.secondsToPixels(interval), + (position) => timeline.secondsToPixels(position), + (position) => formatTime(position, 2), + ); +} + +export function useTimelineTicksBeats(): Ticks { + const timeline = useTimelineStore(); + const { + contentWidthIncludingEmptySpace, + durationBeatsIncludingEmptySpace, + viewportInLoopOffsetBeats, + viewportOutLoopOffsetBeats, + } = storeToRefs(timeline); + + return useTimelineTicks( + viewportInLoopOffsetBeats, + viewportOutLoopOffsetBeats, + useOptimalBeatTickInterval( + contentWidthIncludingEmptySpace, + durationBeatsIncludingEmptySpace, + ), + (interval) => timeline.beatsToPixels(interval), + (position) => timeline.loopOffsetBeatsToPixels(position), + (position) => position.toFixed(), + ); +} diff --git a/Frontend/src/lib/useZoomAxis/index.test.ts b/Frontend/src/lib/useZoomAxis/index.test.ts new file mode 100644 index 0000000..8fe1544 --- /dev/null +++ b/Frontend/src/lib/useZoomAxis/index.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "vitest"; +import { computed, nextTick, shallowRef } from "vue"; +import type { UseZoomAxis } from "."; +import { useZoom, useZoomAxis, zoomDiscreteToRaw, zoomRawToDiscrete } from "."; + +describe("zoom conversion", () => { + test("zoomRawToDiscrete", () => { + expect(zoomRawToDiscrete(1)).toBe(0); + expect(zoomRawToDiscrete(3)).toBe(32); + expect(zoomRawToDiscrete(10)).toBe(122); + expect(zoomRawToDiscrete(19.75)).toBe(200); + // negative + expect(zoomRawToDiscrete(1)).toBe(0); + expect(zoomRawToDiscrete(0.75)).toBe(-10); + expect(zoomRawToDiscrete(0.5)).toBe(-20); + }); + test("zoomDiscreteToRaw", () => { + expect(zoomDiscreteToRaw(0)).toBe(1); + expect(zoomDiscreteToRaw(32)).toBe(3); + expect(zoomDiscreteToRaw(122)).toBe(10); + expect(zoomDiscreteToRaw(200)).toBe(19.75); + // negative + expect(zoomDiscreteToRaw(0)).toBe(1); + expect(zoomDiscreteToRaw(-10)).toBe(0.75); + expect(zoomDiscreteToRaw(-20)).toBe(0.5); + }); +}); + +describe("useZoom", () => { + test("baseline", () => { + useZoom({ raw: 1 }); + useZoom({ raw: 2, min: 0, max: 100 }); + useZoom({ raw: 3, min: -20, max: 200 }); + useZoom({ raw: shallowRef(4) }); + const rawRef = shallowRef(5); + const rawComputed = computed({ + get() { + return rawRef.value; + }, + set(value) { + rawRef.value = value; + }, + }); + const zoom = useZoom({ raw: rawComputed }); + expect(zoom.discrete.value).toBeDefined(); + expect(zoom.raw.value).toBeDefined(); + }); + test("reactive", async () => { + // start with a reactive raw ref + const rawRef = shallowRef(1); + const zoom = useZoom({ raw: rawRef, min: -20, max: 200 }); + + // initial linkage + expect(zoom.raw.value).toBe(1); + expect(zoom.discrete.value).toBe(zoomRawToDiscrete(1)); + + // updating source raw should update discrete + rawRef.value = 3; + await nextTick(); + expect(zoom.raw.value).toBe(3); + expect(zoom.discrete.value).toBe(zoomRawToDiscrete(3)); + + // updating discrete should update raw + zoom.discrete.value = 122; + await nextTick(); + expect(zoom.discrete.value).toBe(122); + expect(zoom.raw.value).toBe(zoomDiscreteToRaw(122)); + expect(rawRef.value).toBe(zoomDiscreteToRaw(122)); + + // setting discrete beyond max should clamp + zoom.discrete.value = 1000; + await nextTick(); + expect(zoom.discrete.value).toBe(200); // clamped to max + expect(zoom.raw.value).toBe(zoomDiscreteToRaw(200)); + expect(rawRef.value).toBe(zoomDiscreteToRaw(200)); + + // setting raw via the wrapper should update discrete + zoom.raw.value = 0.5; + await nextTick(); + expect(zoom.raw.value).toBe(0.5); + expect(zoom.discrete.value).toBe(zoomRawToDiscrete(0.5)); + expect(rawRef.value).toBe(0.5); + }); +}); + +describe("useZoomAxis", () => { + test("baseline", () => { + expect(useZoomAxis).toBeDefined(); + const zoom: UseZoomAxis = useZoomAxis({ raw: 1 }); + expect(zoom.zoom.discrete.value).toBeDefined(); + expect(zoom.min.discrete.value).toBeDefined(); + expect(zoom.max.discrete.value).toBeDefined(); + expect(zoom.default.discrete.value).toBeDefined(); + expect(zoom.stepSmall.discrete.value).toBeDefined(); + zoom.reset(); + zoom.zoomIn(); + zoom.zoomOut(); + + useZoomAxis({ + raw: 1, + min: 0, + max: 10, + default: 5, + stepSmall: 2, + stepBig: 3, + }); + }); + + test("readonly properties are readonly at compile time", () => { + const zoom = useZoomAxis({ raw: 1 }); + + // These lines assert, at compile time, that the properties are readonly. + // If any of these assignments do NOT produce a TS error, the TypeScript + // compiler will fail due to the @ts-expect-error directive. + + // @ts-expect-error Cannot assign to 'value' because it is a read-only property. + zoom.min.raw.value = 32; + // @ts-expect-error Cannot assign to 'value' because it is a read-only property. + zoom.min.discrete.value = 32; + // @ts-expect-error Cannot assign to 'value' because it is a read-only property. + zoom.default.raw.value = 2; + // @ts-expect-error Cannot assign to 'value' because it is a read-only property. + zoom.stepSmall.raw.value = 2; + }); + + test("reset sets zoom to default", async () => { + const z = useZoomAxis({ + raw: 1, + default: 5, + }); + + // change zoom and ensure reset restores default + z.zoom.discrete.value = 0; + await nextTick(); + expect(z.zoom.discrete.value).toBe(0); + + z.reset(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(5); + }); + + test("zoom.raw is writable and reflects source ref", async () => { + const rawRef = shallowRef(1); + const z = useZoomAxis({ raw: rawRef }); + + expect(z.zoom.raw.value).toBe(1); + + z.zoom.raw.value = 3; + await nextTick(); + + expect(z.zoom.raw.value).toBe(3); + expect(rawRef.value).toBe(3); + }); + + test("zoomIn / zoomOut are callable (no runtime throw)", () => { + const z = useZoomAxis({ raw: 1 }); + expect(() => { + z.zoomIn(); + z.zoomOut(); + }).not.toThrow(); + }); + + test("zoomIn snaps up to next big step when between steps", async () => { + const z = useZoomAxis({ raw: 1, stepBig: 10 }); + // set to a value between 10 and 20 + z.zoom.discrete.value = 15; + await nextTick(); + z.zoomIn(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(20); + }); + + test("zoomOut snaps down to previous big step when between steps", async () => { + const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 }); + z.zoomOut(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(10); + }); + + test("zoomIn snaps down to previous big step when between steps", async () => { + const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 }); + z.zoomIn(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(20); + }); + + test("aligned steps add/subtract a whole big step", async () => { + const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), stepBig: 10 }); + + z.zoomIn(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(30); + + z.zoomOut(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(20); + + z.zoomOut(); + await nextTick(); + expect(z.zoom.discrete.value).toBe(10); + }); + + test("zoomIn clamps to max when stepping beyond max", async () => { + const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), max: 25, stepBig: 10 }); + + z.zoomIn(); + await nextTick(); + // should clamp to max (25) instead of exceeding it + expect(z.zoom.discrete.value).toBe(25); + }); +}); diff --git a/Frontend/src/lib/useZoomAxis/index.ts b/Frontend/src/lib/useZoomAxis/index.ts new file mode 100644 index 0000000..aaec6a1 --- /dev/null +++ b/Frontend/src/lib/useZoomAxis/index.ts @@ -0,0 +1,332 @@ +import type { Px, ZoomDiscrete, ZoomRaw } from "@/lib/units"; +import { clamp } from "@vueuse/core"; +import { + computed, + type ComputedRef, + type DeepReadonly, + type MaybeRef, + type Ref, + shallowRef, + toRef, + toValue, + type WritableComputedRef, +} from "vue"; + +export function useZoomAxisOld( + { + contentSizeForZoom, + viewportScrollOffset, + viewportSize, + zoom, + zoomMin, + zoomMax, + }: { + contentSizeForZoom: (zoom?: ZoomRaw) => Px; + viewportScrollOffset: Ref; + viewportSize: Ref; + zoom: Ref; + zoomMin: ZoomRaw; + zoomMax: ZoomRaw; + }, +) { + const contentSize = computed(() => contentSizeForZoom()); + + function contentSizeIncludingEmptySpaceForZoom(zoom?: ZoomRaw): Px { + return Math.max(contentSizeForZoom(zoom), viewportSize.value); + } + const contentSizeIncludingEmptySpace = computed(() => + contentSizeIncludingEmptySpaceForZoom() + ); + + // When zooming, timeline should stay centered at current viewport center + const zoomWrapper = computed({ + get() { + return zoom.value; + }, + set(value) { + // sanitize + value = clamp(value, zoomMin, zoomMax); + // calculate current and anticipated content size + const currentContentSize = contentSizeIncludingEmptySpaceForZoom(); + const nextContentSize = contentSizeIncludingEmptySpaceForZoom( + value, + ); + // calculate current offset of center + const halfViewportSize = viewportSize.value / 2; + const currentOffsetOfCenter = viewportScrollOffset.value + + halfViewportSize; + + // keep the timeline centered around current viewport's center + const percent = currentOffsetOfCenter / currentContentSize; + const nextOffsetOfCenter = percent * nextContentSize; + let nextOffset = nextOffsetOfCenter - halfViewportSize; + const maxOffset = nextContentSize - viewportSize.value; + nextOffset = clamp(nextOffset, 0, maxOffset); + + zoom.value = value; + window.requestAnimationFrame(() => { + viewportScrollOffset.value = nextOffset; + }); + }, + }); + + return { + contentSize, + contentSizeIncludingEmptySpaceForZoom, + contentSizeIncludingEmptySpace, + zoom: zoomWrapper, + }; +} + +export interface UseZoomAxisManagerOptions { + contentSizeForZoom: (zoom: ZoomRaw) => Px; + viewportScrollOffset: Ref; + viewportSize: Readonly>; + zoom: Ref; + zoomMin: ZoomDiscrete; + zoomMax: ZoomDiscrete; + defaultZoom: ZoomDiscrete; +} + +export interface UseZoomAxisManagerReturn { + contentSize: ComputedRef; + contentSizeIncludingEmptySpaceForZoom: (zoom?: ZoomRaw) => Px; + contentSizeIncludingEmptySpace: ComputedRef; + + axis: UseZoomAxis; +} + +const SCALE_INVERSE = 40; +const SCALE_BELOW_THESHOLD = 16; +// no exponential growth: scale is steeper after the threshold +const SCALE_ABOVE_TRESHOLD = 8; +const SCALE_THRESHOLD_DISCRETE: ZoomDiscrete = 100; +const SCALE_THRESHOLD_RAW: ZoomRaw = SCALE_THRESHOLD_DISCRETE / + SCALE_BELOW_THESHOLD; + +export function zoomRawToDiscrete(zoom: ZoomRaw): ZoomDiscrete { + // normalize around zero, so it can scale lineraly + zoom = zoom - 1; + + if (zoom >= 0) { + let belowThreshold = zoom; + let aboveTreshold = 0; + if (zoom > SCALE_THRESHOLD_RAW) { + belowThreshold = SCALE_THRESHOLD_RAW; + aboveTreshold = zoom - SCALE_THRESHOLD_RAW; + } + return belowThreshold * SCALE_BELOW_THESHOLD + + aboveTreshold * SCALE_ABOVE_TRESHOLD; + } else { + return SCALE_INVERSE * zoom; + } +} + +export function zoomDiscreteToRaw(zoom: ZoomDiscrete): ZoomRaw { + if (zoom >= 0) { + let belowThreshold = zoom; + let aboveTreshold = 0; + if (zoom > SCALE_THRESHOLD_DISCRETE) { + belowThreshold = SCALE_THRESHOLD_DISCRETE; + aboveTreshold = zoom - SCALE_THRESHOLD_DISCRETE; + } + return 1 + belowThreshold / SCALE_BELOW_THESHOLD + + aboveTreshold / SCALE_ABOVE_TRESHOLD; + } else { + return 1 + zoom / SCALE_INVERSE; + } +} + +export interface UseZoomOptions { + raw: MaybeRef; + min?: ZoomDiscrete; + max?: ZoomDiscrete; +} + +export interface UseZoom { + raw: Ref; + discrete: Ref; +} + +const DEFAULT_ZOOM_DISCRETE: ZoomDiscrete = 0; +const DEFAULT_ZOOM_MIN_DISCRETE: ZoomDiscrete = 0; +const DEFAULT_ZOOM_MAX_DISCRETE: ZoomDiscrete = 100; +const DEFAULT_ZOOM_STEP_BIG_DISCRETE: ZoomDiscrete = 10; +const DEFAULT_ZOOM_STEP_SMALL_DISCRETE: ZoomDiscrete = 1; + +/** + * zoom raw: suitable for scaling, range 1 .. 7.25 + * zoom discrete: suitable for sliders, range 0 .. 100 or -20 .. 100 + */ +export function useZoom( + options: UseZoomOptions, +): UseZoom { + const { + min: minDiscrete = DEFAULT_ZOOM_MIN_DISCRETE, + max: maxDiscrete = DEFAULT_ZOOM_MAX_DISCRETE, + } = options; + + const minRaw = zoomDiscreteToRaw(minDiscrete); + const maxRaw = zoomDiscreteToRaw(maxDiscrete); + + const rawRef = toRef(options.raw); + const raw = computed({ + get() { + return clamp(rawRef.value, minRaw, maxRaw); + }, + set(newRaw) { + rawRef.value = clamp(newRaw, minRaw, maxRaw); + }, + }); + + const discrete = computed({ + get() { + return clamp(zoomRawToDiscrete(rawRef.value), minDiscrete, maxDiscrete); + }, + set(newDiscrete) { + rawRef.value = zoomDiscreteToRaw( + clamp(newDiscrete, minDiscrete, maxDiscrete), + ); + }, + }); + + return { + raw, + discrete, + }; +} + +export interface UseZoomAxisOptions { + raw: MaybeRef; + // limits + min?: ZoomDiscrete; + max?: ZoomDiscrete; + default?: ZoomDiscrete; + // Can be used for granular controls like a range slider + stepSmall?: ZoomDiscrete; + // Can be used for buttons + stepBig?: ZoomDiscrete; +} + +export interface UseZoomAxis { + zoom: UseZoom; + + // Can be used by granular controls like a range slider + min: DeepReadonly; + max: DeepReadonly; + default: DeepReadonly; + stepSmall: DeepReadonly; + + // Can be triggered by double clicking on the control + reset: () => void; + // Can be used by buttons. Zoom values between big steps will snap to the nearest whole step in the given direction, otherwise adds or subtracts a whole step. + zoomIn: () => void; + zoomOut: () => void; +} + +export function useZoomAxis(options: UseZoomAxisOptions): UseZoomAxis { + const { + raw, + min: minDiscrete = DEFAULT_ZOOM_MIN_DISCRETE, + max: maxDiscrete = DEFAULT_ZOOM_MAX_DISCRETE, + default: defaultDiscrete = DEFAULT_ZOOM_DISCRETE, + stepSmall: stepSmallDiscrete = DEFAULT_ZOOM_STEP_SMALL_DISCRETE, + stepBig: stepBigDiscrete = DEFAULT_ZOOM_STEP_BIG_DISCRETE, + } = options; + + const zoom = useZoom({ raw, min: minDiscrete, max: maxDiscrete }); + const min = useZoom({ raw: zoomDiscreteToRaw(minDiscrete) }); + const max = useZoom({ raw: zoomDiscreteToRaw(maxDiscrete) }); + const default_ = useZoom({ raw: zoomDiscreteToRaw(defaultDiscrete) }); + const stepSmall = useZoom({ raw: zoomDiscreteToRaw(stepSmallDiscrete) }); + + function reset() { + zoom.discrete.value = defaultDiscrete; + } + function zoomStep(direction: number): void { + let z = zoom.discrete.value - minDiscrete; + if (z % stepBigDiscrete !== 0) { + // go to the nearest full step up or down depending on the direction + z = ((direction > 0) ? Math.ceil : Math.floor)(z / stepBigDiscrete); + zoom.discrete.value = minDiscrete + z * stepBigDiscrete; + } else { + zoom.discrete.value += direction * stepBigDiscrete; + } + } + function zoomIn() { + zoomStep(+1); + } + function zoomOut() { + zoomStep(-1); + } + + return { + zoom, + + min, + max, + default: default_, + stepSmall, + + reset, + zoomIn, + zoomOut, + }; +} + +// export function useZoomAxisManager( +// { +// contentSizeForZoom, +// viewportScrollOffset, +// viewportSize, +// zoom, +// zoomMin, +// zoomMax, +// }: UseZoomAxisManagerOptions, +// ): UseZoomAxisManagerReturn { +// const contentSize = computed(() => contentSizeForZoom()); + +// function contentSizeIncludingEmptySpaceForZoom(zoom?: number): number { +// return Math.max(contentSizeForZoom(zoom), viewportSize.value); +// } +// const contentSizeIncludingEmptySpace = computed(() => +// contentSizeIncludingEmptySpaceForZoom() +// ); + +// // When zooming, timeline should stay centered at current viewport center +// const zoomWrapper = computed({ +// get() { +// return zoom.value; +// }, +// set(value) { +// // sanitize +// value = clamp(value, zoomMin, zoomMax); +// // calculate current and anticipated content size +// const currentContentSize = contentSizeIncludingEmptySpaceForZoom(); +// const nextContentSize = contentSizeIncludingEmptySpaceForZoom( +// value, +// ); +// // calculate current offset of center +// const halfViewportSize = viewportSize.value / 2; +// const currentOffsetOfCenter = viewportScrollOffset.value + +// halfViewportSize; + +// // keep the timeline centered around current viewport's center +// const percent = currentOffsetOfCenter / currentContentSize; +// const nextOffsetOfCenter = percent * nextContentSize; +// let nextOffset = nextOffsetOfCenter - halfViewportSize; +// const maxOffset = nextContentSize - viewportSize.value; +// nextOffset = clamp(nextOffset, 0, maxOffset); + +// zoom.value = value; +// viewportScrollOffset.value = nextOffset; +// }, +// }); + +// return { +// contentSize, +// contentSizeIncludingEmptySpaceForZoom, +// contentSizeIncludingEmptySpace, +// zoom: zoomWrapper, +// }; +// } diff --git a/Frontend/src/lib/vue.ts b/Frontend/src/lib/vue.ts new file mode 100644 index 0000000..f13999f --- /dev/null +++ b/Frontend/src/lib/vue.ts @@ -0,0 +1,47 @@ +import { + computed, + type MaybeRefOrGetter, + type Ref, + toValue, + watch, + type WatchHandle, +} from "vue"; + +export function usePx(value: MaybeRefOrGetter) { + return computed(() => toPx(value)); +} + +export function toPx(value: MaybeRefOrGetter) { + return `${toValue(value)}px`; +} + +function multiWatchHandle(...handles: WatchHandle[]): WatchHandle { + const watchHandle = () => { + handles.forEach((h) => h.stop()); + } + watchHandle.pause = () => handles.forEach((h) => h.pause()); + watchHandle.resume = () => handles.forEach((h) => h.resume()); + watchHandle.stop = watchHandle; + + return watchHandle; +} + +export function bindTwoWay(ref1: Ref, ref2: Ref): WatchHandle { + let updating = false; + + function update(other: Ref, value: T) { + if (updating) return; + // For some reason, useScroll reports undefined values sometimes + if (value === undefined) return; + updating = true; + try { + other.value = value; + } finally { + updating = false; + } + } + const handle1 = watch(ref1, (value) => update(ref2, value)); + const handle2 = watch(ref2, (value) => update(ref1, value)); + + return multiWatchHandle(handle1, handle2); +} diff --git a/Frontend/src/main.ts b/Frontend/src/main.ts new file mode 100644 index 0000000..f77adb2 --- /dev/null +++ b/Frontend/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import "@/style.css"; +import App from "@/App.vue"; +import { router } from "@/router"; + +const pinia = createPinia(); +const app = createApp(App); + +app.use(pinia); +app.use(router); +app.mount("#app"); diff --git a/Frontend/src/reset.css b/Frontend/src/reset.css new file mode 100644 index 0000000..c29877b --- /dev/null +++ b/Frontend/src/reset.css @@ -0,0 +1,48 @@ +/* + Josh's Custom CSS Reset + https://www.joshwcomeau.com/css/custom-css-reset/ +*/ + +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; +} + +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; +} + +h1, h2, h3, h4, h5, h6 { + text-wrap: balance; +} + +#app { + isolation: isolate; +} diff --git a/Frontend/src/router.ts b/Frontend/src/router.ts new file mode 100644 index 0000000..237a2b0 --- /dev/null +++ b/Frontend/src/router.ts @@ -0,0 +1,57 @@ +import LibraryPage from "@/routes/LibraryPage.vue"; +import PlayerPage from "@/routes/PlayerPage.vue"; +import { computed, type ComputedRef, nextTick, shallowRef } from "vue"; +import { createRouter, createWebHashHistory, useRoute } from "vue-router"; +import type { AudioTrack } from "./lib/AudioTrack"; + +const routes = [ + { + path: "/", + component: LibraryPage, + meta: { + title() { + return "Library"; + }, + }, + }, + { + path: "/track/:trackName", + component: PlayerPage, + meta: { + title() { + const trackName = router.currentRoute.value.params["trackName"] ?? + "No track selected"; + return trackName; + }, + }, + }, +]; + +export const router = createRouter({ + history: createWebHashHistory(), + routes, +}); + +export const DEFAULT_TITLE = "MuzikaGromche"; +const titleRef = shallowRef(""); + +router.afterEach((to, _from) => { + const route = useRoute(); + let title = ""; + if (typeof to.meta.title === "function") { + title = to.meta.title(route); + } + titleRef.value = title; + nextTick(() => { + const documentTitle = title ? `${title} — ${DEFAULT_TITLE}` : DEFAULT_TITLE; + document.title = documentTitle; + }); +}); + +export function useTitle(): ComputedRef { + return computed(() => titleRef.value); +} + +export function openTrack(track: AudioTrack) { + router.push(`/track/${track.Name}`); +} diff --git a/Frontend/src/routes/LibraryPage.vue b/Frontend/src/routes/LibraryPage.vue new file mode 100644 index 0000000..fe78580 --- /dev/null +++ b/Frontend/src/routes/LibraryPage.vue @@ -0,0 +1,35 @@ + + + + diff --git a/Frontend/src/routes/PlayerPage.vue b/Frontend/src/routes/PlayerPage.vue new file mode 100644 index 0000000..b2a5a92 --- /dev/null +++ b/Frontend/src/routes/PlayerPage.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/Frontend/src/store/ScrollStore.ts b/Frontend/src/store/ScrollStore.ts new file mode 100644 index 0000000..c3c1f4d --- /dev/null +++ b/Frontend/src/store/ScrollStore.ts @@ -0,0 +1,32 @@ +import { emitter, useEvent } from "@/events"; +import { useScroll } from "@vueuse/core"; +import { defineStore } from "pinia"; +import { type MaybeRefOrGetter, toValue, watch } from "vue"; + +export const useScrollStore = defineStore("scroll", { + state: () => { + return ({ + isAtTop: true, + }); + }, + actions: { + scrollToTop() { + emitter.emit("scrollToTop"); + }, + bindScrollContainer(element: MaybeRefOrGetter) { + const { y, arrivedState } = useScroll(element, { behavior: "smooth" }); + + watch(arrivedState, () => { + this.isAtTop = arrivedState.top; + }, { immediate: true }); + + useEvent("scrollToTop", () => { + const _element = toValue(element); + if (!_element) { + return; + } + y.value = 0; + }); + }, + }, +}); diff --git a/Frontend/src/store/TimelineStore.ts b/Frontend/src/store/TimelineStore.ts new file mode 100644 index 0000000..bc12d28 --- /dev/null +++ b/Frontend/src/store/TimelineStore.ts @@ -0,0 +1,404 @@ +import { + type AudioTrack, + beatsToSeconds, + introWithLoopOffsetDurationSeconds, + secondsToBeats, + totalDurationSeconds, +} from "@/lib/AudioTrack"; +import { modRange } from "@/lib/math"; +import { + emptyTimelineTracksMap, + generateClips, + generateMarkers, + type TimelineMarkerData, + type TimelineTrackData, + timelineTracksArray, +} from "@/lib/Timeline"; +import type { Beats, Px, Seconds } from "@/lib/units"; +import { useZoomAxisOld } from "@/lib/useZoomAxis"; +import { toPx } from "@/lib/vue"; +import { clamp, useLocalStorage } from "@vueuse/core"; +import { defineStore } from "pinia"; +import { computed, shallowRef } from "vue"; + +export const DEFAULT_ZOOM_HORIZONTAL = 1.0; +export const DEFAULT_ZOOM_VERTICAL = 3.0; + +const DEFAULT_HEADER_HEIGHT = 34; // px +// TODO: on mobile default to 100px or even less +const DEFAULT_SIDEBAR_WIDTH = 140; // px +const DEFAULT_TRACK_HEIGHT = 72 / DEFAULT_ZOOM_VERTICAL; // px + +const EXTRA_DURATION_AT_END_SECONDS = 0; + +export const useTimelineStore = defineStore("timeline", { + state: () => { + // actual content + const audioTrack = shallowRef(null); + const tracksMap = shallowRef(emptyTimelineTracksMap()); + const markers = [] as TimelineMarkerData[]; + + // viewport size, i.e. size of the external scroll area. + const viewportWidth = shallowRef(0); + const viewportHeight = shallowRef(0); + + // viewport scroll offset, i.e. position of the inner scrollable view. + const viewportScrollOffsetTop = shallowRef(0); + const _viewportScrollOffsetLeft = shallowRef(0); + const viewportScrollOffsetLeft = computed({ + get() { + return _viewportScrollOffsetLeft.value; + }, + set(value) { + _viewportScrollOffsetLeft.value = clamp( + value, + 0, + contentWidthIncludingEmptySpace.value - viewportWidth.value, + ); + }, + }); + + // horizontal zoom 1 equals to full timeline duration + const _viewportZoomHorizontal = shallowRef(DEFAULT_ZOOM_HORIZONTAL); + const _viewportZoomVertical = useLocalStorage( + "timeline.viewportZoomVertical", + DEFAULT_ZOOM_VERTICAL, + ); + + function trackHeightForZoom(zoom: number = _viewportZoomVertical.value) { + return Math.floor(zoom * DEFAULT_TRACK_HEIGHT); + } + const trackHeight = computed(() => trackHeightForZoom()); + const tracksArray = computed(() => + timelineTracksArray(tracksMap.value) + ); + const visibleTracks = computed(() => + // TODO: only show non-empty tracks, i.e. where clips.length>0? + tracksArray.value.slice(0, 7).filter(() => true) + ); + + function contentWidthForZoom(zoom: number = _viewportZoomHorizontal.value) { + return Math.floor(zoom * viewportWidth.value); + } + function contentHeightForZoom(zoom?: number) { + const trackHeight = trackHeightForZoom(zoom); + return trackHeight * visibleTracks.value.length; + } + + // TODO: zoom around playhead + const { + contentSize: contentWidth, + // contentSizeIncludingEmptySpaceForZoom: contentWidthIncludingEmptySpaceForZoom, + contentSizeIncludingEmptySpace: contentWidthIncludingEmptySpace, + zoom: viewportZoomHorizontal, + } = useZoomAxisOld({ + contentSizeForZoom: contentWidthForZoom, + viewportScrollOffset: viewportScrollOffsetLeft, + viewportSize: viewportWidth, + zoom: _viewportZoomHorizontal, + zoomMin: 0.5, + zoomMax: 19.75, + }); + + const { + contentSize: contentHeight, + // contentSizeIncludingEmptySpaceForZoom: contentHeightIncludingEmptySpaceForZoom, + contentSizeIncludingEmptySpace: contentHeightIncludingEmptySpace, + zoom: viewportZoomVertical, + } = useZoomAxisOld({ + contentSizeForZoom: contentHeightForZoom, + viewportScrollOffset: viewportScrollOffsetTop, + viewportSize: viewportHeight, + zoom: _viewportZoomVertical, + zoomMin: 1, + zoomMax: 7.25, + }); + + return ({ + // actual content + audioTrack, + tracksMap, + markers, + + // getters for tracks + tracksArray, + visibleTracks, + + // timeline content's duration in seconds (maybe plus a bit of extra gap) + duration: 0, + + trackHeight, + + contentWidth, + contentHeight, + + // durationIncludingEmptySpace, + contentWidthIncludingEmptySpace, + contentHeightIncludingEmptySpace, + + /* viewport bounds, updated by viewport's mounted HTML element. */ + + viewportWidth, + viewportHeight, + + viewportScrollOffsetTop, + viewportScrollOffsetLeft, + + /* viewport zoom, managed by zoom sliders. */ + + // horizontal zoom 1 equals to full timeline duration + viewportZoomHorizontal, + viewportZoomVertical, + + // playhead and scrubbing / preview positions in absolute seconds + playheadPosition: 0, + scrubbingPosition: NaN, + + // auxilary elements + headerHeight: DEFAULT_HEADER_HEIGHT, + sidebarWidth: DEFAULT_SIDEBAR_WIDTH, + }); + }, + getters: { + contentWidthIncludingEmptySpacePx(): string { + return toPx(this.contentWidthIncludingEmptySpace); + }, + contentHeightIncludingEmptySpacePx(): string { + return toPx(this.contentHeightIncludingEmptySpace); + }, + durationIncludingEmptySpace(): number { + return this.viewportZoomHorizontal < 1 + ? this.duration / this.viewportZoomHorizontal + : this.duration; + }, + durationBeatsIncludingEmptySpace(): number { + if (!this.audioTrack) return 0; + return secondsToBeats(this.audioTrack, this.durationIncludingEmptySpace); + }, + /* timeline content's size in pixels */ + contentWidthPx(): string { + return toPx(this.contentWidth); + }, + contentHeightPx(): string { + return toPx(this.contentHeight); + }, + + /* viewport boundaries in absolute seconds, may be less than zero or greater then duration. */ + viewportInSeconds(): Seconds { + return this.pixelsToSeconds(this.viewportScrollOffsetLeft); + }, + viewportOutSeconds(): Seconds { + return this.pixelsToSeconds( + this.viewportScrollOffsetLeft + this.viewportWidth, + ); + }, + viewportInLoopOffsetBeats(): Beats { + if (!this.audioTrack) return 0; + return secondsToBeats( + this.audioTrack, + this.viewportInSeconds - + introWithLoopOffsetDurationSeconds(this.audioTrack), + ); + }, + viewportOutLoopOffsetBeats(): Beats { + if (!this.audioTrack) return 0; + return secondsToBeats( + this.audioTrack, + this.viewportOutSeconds - + introWithLoopOffsetDurationSeconds(this.audioTrack), + ); + }, + viewportDurationSeconds(): Seconds { + return this.pixelsToSeconds(this.viewportWidth); + }, + viewportCenterSeconds(): Seconds { + return this.viewportInSeconds + + (this.viewportOutSeconds - this.viewportInSeconds) / 2; + }, + viewportScrollOffset(): { top: Px; left: Px } { + return { + top: this.viewportScrollOffsetTop, + left: this.viewportScrollOffsetLeft, + }; + }, + viewportSide() { + return (positionSeconds: Seconds): "left" | "right" => { + return positionSeconds < this.viewportCenterSeconds ? "left" : "right"; + }; + }, + durationBeats(): Beats { + if (!this.audioTrack) { + return 0; + } + return secondsToBeats(this.audioTrack, this.duration); + }, + playheadPositionBeats(): Beats { + if (!this.audioTrack) { + return 0; + } + return secondsToBeats(this.audioTrack, this.playheadPosition); + }, + scrubbingPositionBeats(): Beats { + if (!this.audioTrack) { + return 0; + } + if (Number.isNaN(this.scrubbingPosition)) { + return NaN; + } + return secondsToBeats(this.audioTrack, this.scrubbingPosition); + }, + /* Measurements and convertions */ + pixelsToSeconds(_state) { + return (pixels: number): number => { + const { contentWidthIncludingEmptySpace, durationIncludingEmptySpace } = + this; + if (contentWidthIncludingEmptySpace === 0) { + return 0; + } + const percent = pixels / contentWidthIncludingEmptySpace; + const seconds = percent * durationIncludingEmptySpace; + return seconds; + }; + }, + secondsToPixels(_state) { + return (seconds: number): number => { + const { contentWidthIncludingEmptySpace, durationIncludingEmptySpace } = + this; + if ( + durationIncludingEmptySpace === 0 || + contentWidthIncludingEmptySpace === 0 + ) { + return 0; + } + const percent = seconds / durationIncludingEmptySpace; + // TODO: contentWidth - 1 from content width to avoid clipping out of bounds, or just make duration always longer? + const pixels = percent * contentWidthIncludingEmptySpace; + // TODO: do we need Math.round() at this level? + return pixels; + }; + }, + beatsToPixels(_state) { + return (beats: number): number => { + const { + contentWidthIncludingEmptySpace, + durationBeatsIncludingEmptySpace, + } = this; + if ( + durationBeatsIncludingEmptySpace === 0 || + contentWidthIncludingEmptySpace === 0 + ) { + return 0; + } + const percent = beats / durationBeatsIncludingEmptySpace; + // TODO: contentWidth - 1 from content width to avoid clipping out of bounds, or just make duration always longer? + const pixels = percent * contentWidthIncludingEmptySpace; + // TODO: do we need Math.round() at this level? + return pixels; + }; + }, + loopOffsetBeatsToPixels(_state) { + return (beats: number): number => { + if (!this.audioTrack) return 0; + const pixels = this.beatsToPixels(beats) + + this.secondsToPixels( + introWithLoopOffsetDurationSeconds(this.audioTrack), + ); + return pixels; + }; + }, + }, + actions: { + reset() { + this.audioTrack = null; + this.duration = 0; + this.resetViewport(); + this.playheadPosition = 0; + this.scrubbingPosition = NaN; + this.tracksMap = emptyTimelineTracksMap(); + this.markers = []; + }, + resetViewport() { + this.viewportZoomHorizontal = DEFAULT_ZOOM_HORIZONTAL; + // Keep it in local storage. + // this.viewportZoomVertical = DEFAULT_ZOOM_VERTICAL; + }, + zoomToLoop() { + const { audioTrack } = this; + if (!audioTrack) return; + this.zoomToLoopOffsetBeats(0, audioTrack.Beats); + }, + zoomToLoopOffsetBeats(in_: Beats, out_: Beats) { + const { audioTrack } = this; + if (!audioTrack || out_ <= in_) return; + beatsToSeconds(audioTrack, in_); + const inSeconds = introWithLoopOffsetDurationSeconds(audioTrack) + + beatsToSeconds(audioTrack, in_); + const duration = beatsToSeconds(audioTrack, out_ - in_); + const outSeconds = inSeconds + duration; + this.zoomToSeconds(inSeconds, outSeconds); + }, + zoomToSeconds(in_: Seconds, out_: Seconds) { + const { audioTrack } = this; + if (!audioTrack || out_ <= in_) return; + const duration = out_ - in_; + const totalDuration = totalDurationSeconds(audioTrack); + const zoom = totalDuration / duration; + this.viewportZoomHorizontal = zoom; + // let the viewport adjust and propagate size changes. + window.requestAnimationFrame(() => { + const left = this.secondsToPixels(in_); + this.viewportScrollOffsetLeft = left; + }); + }, + zoomToggleBetweenWholeAndLoop() { + if (this.viewportZoomHorizontal !== 1) { + this.viewportZoomHorizontal = 1; + } else { + this.zoomToLoop(); + } + }, + setAudioTrack(track: AudioTrack | null) { + if (!track) { + this.reset(); + return; + } + this.audioTrack = track; + this.duration = totalDurationSeconds(track) + + EXTRA_DURATION_AT_END_SECONDS; + this.resetViewport(); + this.playheadPosition = 0; + this.scrubbingPosition = NaN; + // regenerate tracks content + this.tracksMap = generateClips(track); + this.markers = generateMarkers(track); + }, + /** Update playback position */ + advance(deltaSeconds: number) { + const { audioTrack } = this; + if (!audioTrack) { + return; + } + const startOfLoop = introWithLoopOffsetDurationSeconds(audioTrack); + const endOfLoop = totalDurationSeconds(audioTrack); + let position = this.playheadPosition + deltaSeconds; + position = modRange(position, startOfLoop, endOfLoop); + this.playheadPosition = position; + this.ensurePlayheadWithinViewport(); + }, + ensurePlayheadWithinViewport() { + if ( + this.playheadPosition < this.viewportInSeconds || + this.playheadPosition > this.viewportOutSeconds + ) { + const EDGE_GAP_PERCENT = 0.10; + const target = this.secondsToPixels(this.playheadPosition) - + this.viewportWidth * EDGE_GAP_PERCENT; + this.viewportScrollOffsetLeft = clamp( + target, + 0, + this.contentWidth - this.viewportWidth, + ); + } + }, + }, +}); diff --git a/Frontend/src/store/TrackStore.ts b/Frontend/src/store/TrackStore.ts new file mode 100644 index 0000000..b7e2d40 --- /dev/null +++ b/Frontend/src/store/TrackStore.ts @@ -0,0 +1,280 @@ +import audioEngine, { VOLUME_MAX } from "@/audio/AudioEngine"; +import { introWithLoopOffsetDurationSeconds, totalDurationSeconds, type AudioTrack, type Codenames, type Language } from "@/lib/AudioTrack"; +import { useStorage } from "@vueuse/core"; +import { defineStore } from "pinia"; +import { shallowRef } from "vue"; +import codenamesJsonUrl from "/MuzikaGromcheCodenames.json?url"; +import tracksJsonUrl from "/MuzikaGromcheTracks.json?url"; +import { sleep } from "@/lib/sleep"; + +// Don't mark it as unused, it is needed for debugging +sleep(0); + +/** + * A Pinia Store responsible for library loading and communicating with audio engine. + */ +export const useTrackStore = defineStore("track", { + state: () => { + return ({ + version: null as string | null, + + // Status of fetching library (playlist) + status: "null" as "null" | "loading" | "ready" | "error", + // loading progress: 0..1 + progress: 0, + error: null as string | null, + + // Status of fetching current track + audioTrackStatus: "null" as "null" | "loading" | "ready" | "error", + audioTrackProgress: 0 as number, + audioTrackError: null as string | null, + + audioTracks: [] as AudioTrack[], + + currentAudioTrackName: useStorage("track-name", ""), + currentAudioTrack: shallowRef(null), + + // when muted, volume persists but not accounted for + muted: useStorage("player-volume-muted", false), + // persisted volume 0..1 + volume: useStorage("player-volume", 1), + + // audio engine manages AudioContext and nodes; store keeps serializable state only + isPlaying: false, + // Playback time elapsed since start of intro until the audio was paused. + // This is needed because Audio Nodes can not be resumed. + playedDuration: 0, + }); + }, + getters: { + groupedSortedTracks(): [Language, AudioTrack[]][] { + const languageBuckets = new Map(); + for (const track of this.audioTracks) { + const bucket = languageBuckets.get(track.Language) ?? []; + languageBuckets.set(track.Language, bucket); + bucket.push(track); + } + for (const bucket of languageBuckets.values()) { + bucket.sort((a, b) => a.Name.localeCompare(b.Name)); + } + return Array.from(languageBuckets.entries()).sort(([a], [b]) => + a.localeCompare(b) + ); + }, + findTrackNamed(state) { + return (trackName: string): AudioTrack | null => { + return state.audioTracks.find((track) => track.Name === trackName) ?? + null; + }; + }, + // TODO: replace with TimelineStore + timelineTotalDurationSeconds(state) { + return (track: AudioTrack | null = state.currentAudioTrack) => { + return track ? totalDurationSeconds(track) : 0; + }; + }, + trackStartOfLoopSeconds(state) { + return (track: AudioTrack | null = state.currentAudioTrack) => { + return track ? introWithLoopOffsetDurationSeconds(track) : 0; + }; + }, + }, + actions: { + async fill(signal?: AbortSignal) { + if (this.status === "ready" && this.audioTracks.length > 0) { + return; + } + this.status = "loading"; + this.progress = 0; + this.error = null; + this.audioTracks = []; + // await sleep(200); + this.progress = 0.3; + // await sleep(600); + try { + const jsonTracks = await fetch(tracksJsonUrl, { signal }).then((res) => + res.json() + ); + this.version = jsonTracks["version"]; + const tracks: Partial[] = jsonTracks["tracks"]; + const codenames: Codenames = await fetch(codenamesJsonUrl, { signal }) + .then((res) => res.json()); + for (const t of tracks) { + const codename = codenames[t.Name!]; + t.Artist = codename?.Artist ?? ""; + t.Song = codename?.Song ?? ""; + t.loadedIntro = null; + t.loadedLoop = null; + } + this.audioTracks = tracks as AudioTrack[]; + this.progress = 1.0; + this.status = "ready"; + } catch (err) { + this.error = String(err); + this.progress = 1.0; + this.status = "error"; + } + // initialize audio engine volume from persisted value + this.setVolumeImpl(); + await this.setCurrentAudioTrackByName(this.currentAudioTrackName, signal); + }, + + setMuted(muted: boolean) { + if (!muted && this.volume === 0) { + this.volume = 0.5; + } + this.muted = muted; + this.setVolumeImpl(); + }, + + setVolume(value: number) { + // clamp 0..1 + const v = Math.max(0, Math.min(VOLUME_MAX, value)); + // update persisted storage + this.volume = v; + this.muted = v === 0; + this.setVolumeImpl(); + }, + + setVolumeImpl() { + const v = this.muted ? 0 : this.volume; + audioEngine.setVolume(v); + }, + + async setCurrentAudioTrackByName(trackName: string, signal?: AbortSignal) { + this.pause(); + this.rewindToIntro(); + this.currentAudioTrackName = trackName; + const track = this.findTrackNamed(trackName); + this.currentAudioTrack = track; + this.audioTrackStatus = "null"; + this.audioTrackProgress = 0; + this.audioTrackError = null; + + if (track !== null) { + await this.loadAudioTrack(track, signal); + } + }, + + async loadAudioTrack(track: AudioTrack, signal?: AbortSignal) { + if (track.loadedIntro && track.loadedLoop) { + this.audioTrackError = null; + this.audioTrackProgress = 1; + this.audioTrackStatus = "ready"; + return; + } + + this.audioTrackStatus = "loading"; + this.audioTrackProgress = 0; + this.audioTrackError = null; + track.loadedIntro = null; + track.loadedLoop = null; + + const errors: string[] = []; + + for ( + const [fileName, setter] of [ + [track.FileNameIntro, (buffer: AudioBuffer | null) => { + track.loadedIntro = buffer; + }] as const, + [track.FileNameLoop, (buffer: AudioBuffer | null) => { + track.loadedLoop = buffer; + }] as const, + ] + ) { + const dir = import.meta.env.BASE_URL + "/MuzikaGromcheAudio"; + const url = `${dir}/${fileName}`; + try { + const buffer = await this.fetchAudioBuffer(url, signal); + setter(buffer); + } catch (err) { + errors.push(`Failed to load audio '${url}': ${err}`); + setter(null); + } + this.audioTrackProgress = 0.5; + } + if (errors.length > 0) { + this.audioTrackError = errors.join("; "); + this.audioTrackProgress = 1; + this.audioTrackStatus = "error"; + } else { + this.audioTrackProgress = 1; + this.audioTrackStatus = "ready"; + } + }, + + play() { + const track = this.currentAudioTrack; + if (!track || this.audioTrackStatus !== "ready") return; + if (!track.loadedIntro || !track.loadedLoop) return; + + audioEngine.playBuffers( + track.loadedIntro, + track.loadedLoop, + this.playedDuration, + ); + this.isPlaying = true; + }, + + pause() { + audioEngine.pause(); + // read current position from engine and store it + this.playedDuration = audioEngine.getPosition(); + this.isPlaying = false; + }, + + togglePlayPause({ shouldBePlaying }: { shouldBePlaying?: boolean } = {}) { + if (shouldBePlaying === undefined) { + shouldBePlaying = !this.isPlaying; + } else if (shouldBePlaying === this.isPlaying) { + return; + } + if (shouldBePlaying) { + this.play(); + } else { + this.pause(); + } + }, + + stop() { + audioEngine.shutdown(); + this.playedDuration = 0; + this.isPlaying = false; + }, + + rewindToIntro() { + this.pause(); + this.playedDuration = 0; + }, + + rewindToWindUp() { + this.pause(); + // let target = 0; + // if (this.currentAudioTrack) { + const preWindUpGap = 3; // seconds + // // toggle between exact wind-up moment and a short build-up before that. + // const current = this.playedDuration; + // const exactWindUp = this.currentAudioTrack.WindUpTimer; + // const beforeWindUp = exactWindUp - preWindUpGap; + // target = current !== beforeWindUp ? beforeWindUp : exactWindUp; + // console.log("AAAAAAA", current, exactWindUp, beforeWindUp, current !== beforeWindUp, target); + // } + // this.playedDuration = target; + this.playedDuration = this.currentAudioTrack ? this.currentAudioTrack.WindUpTimer - preWindUpGap : 0; + }, + + rewindToLoop() { + this.pause(); + const t = this.currentAudioTrack; + this.playedDuration = t ? introWithLoopOffsetDurationSeconds(t) : 0; + }, + + // Delegate fetching/decoding to AudioEngine (it has caching) + async fetchAudioBuffer( + url: string, + signal?: AbortSignal, + ): Promise { + return await audioEngine.fetchAudioBuffer(url, signal); + }, + }, +}); diff --git a/Frontend/src/style.css b/Frontend/src/style.css new file mode 100644 index 0000000..2ef18d3 --- /dev/null +++ b/Frontend/src/style.css @@ -0,0 +1,153 @@ +@import "tailwindcss" prefix(tw); +@import "./reset.css"; + +* { + --main-background-color: #28282e; + --inactive-text-color: #909090; + --active-text-color: #ffffff; + + --view-separator-color: #090909; + --view-separator-border: 1px solid var(--view-separator-color); + + --header-background-color: #17181a; + --toolbar-background-color: #212126; + --view-background-color: #212126; + --card-background-color: #2a2a2d; + --card-border-color: #000000; + --card-border-width: 1px; + --card-border-radius: 4px; + --card-border: var(--card-border-width) solid var(--card-border-color); + --card-separator-color: #212126; + --card-separator-width: 2px; + --card-outline-color: #929292; + --card-outline-selected-color: #fa5b4a; + --card-min-width: 24rem; + + --input-background-color: #1f1f1f; + --input-outline-color: #070707; + --input-outline-selected-color: #e64b3d; + --input-border-width: 1px; + --input-border-radius: 4px; + + --timeline-background-color: var(--main-background-color); + --timeline-background-top-color: #18181e; + --timeline-border-top-color: var(--view-separator-color); + --timeline-header-separator-color: #000000; + --timeline-header-tick-edge-color: #2f3036; + /* + track layout: + border-top + ...track content... + border-bottom + --- border (separator) --- + border-top + ...track content... + border-bottom + */ + --timeline-track-border-color: #00000080; + --timeline-track-border: 1px solid var(--timeline-track-border-color); + --timeline-track-border-top-color: #00000033; + --timeline-track-border-top: 1px solid var(--timeline-track-border-top-color); + --timeline-track-border-bottom-color: #0000003a; + --timeline-track-border-bottom: 1px solid var(--timeline-track-border-bottom-color); + --timeline-text-color: #909090; + --timeline-bar-color: #fffff0; + --timeline-bar-opacity: 11%; + --timeline-bar-width: 1px; + + --timeline-playhead-color: #e64b3d; + + --timeline-marker-beat-color: #ffffff1c; + + --timeline-clip-border-color: #15151580; + --timeline-clip-border-color-inner: #151515; + --timeline-clip-border-radius: 4px; + + /* + * TODO: + * timeline clip selected outline: + * inner 1px black + * outer 2px red + */ + --timeline-clip-outline-selected-color: #e64b3d; + --timeline-clip-outline-selected-width: 2px; + --timeline-clip-outline-selected: var(--timeline-clip-outline-selected-width) solid var(--timeline-clip-outline-selected-color); + + --timeline-clip-color-orange: #eb6e01; + --timeline-clip-color-apricot: #ffa833; + --timeline-clip-color-yellow: #d4ad1f; + --timeline-clip-color-lime: #9fc615; + --timeline-clip-color-olive: #5f9921; + --timeline-clip-color-green: #448f65; + --timeline-clip-color-teal: #019899; + --timeline-clip-color-navy: #005278; + --timeline-clip-color-blue: #4376a1; + --timeline-clip-color-purple: #9972a0; + --timeline-clip-color-violet: #d0568d; + --timeline-clip-color-pink: #e98cb5; + --timeline-clip-color-tan: #b9af97; + --timeline-clip-color-beige: #c4a07c; + --timeline-clip-color-brown: #996601; + --timeline-clip-color-chocolate: #8c5a3f; + + --timeline-clip-label-background-color: #00000099; + --timeline-clip-label-border-color: #00000060; + + --timeline-clip-baseline-color: #00000033; +} + +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: var(--main-background-color); + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +body { + padding: 0; + min-width: 320px; + height: 100vh; +} + +#app { + height: 100%; +} + +@layer utilities { + .toolbar-background { + background-color: var(--toolbar-background-color); + } + + .timeline-background { + background-color: var(--timeline-background-color); + } + + .scrollbar-none { + scrollbar-width: none; + } +} + +@layer components { + .card-border { + border: 1px solid var(--card-border-color); + border-radius: var(--card-border-radius); + } +} diff --git a/Frontend/tests/components/SearchField.spec.ts b/Frontend/tests/components/SearchField.spec.ts new file mode 100644 index 0000000..05b8565 --- /dev/null +++ b/Frontend/tests/components/SearchField.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "vitest"; +import { render } from "vitest-browser-vue"; +import SearchField from "@/components/SearchField.vue"; + +test("default placeholder", async () => { + const { getByRole } = render(SearchField); + + const searchBox = getByRole("searchbox"); + await expect.element(searchBox).toBeInTheDocument(); + await expect.element(searchBox).toHaveAttribute("placeholder", "Search…"); +}); + +test("custom placeholder", async () => { + const customPlaceholder = "Hello there"; + const { getByRole } = render(SearchField, { + props: { placeholder: customPlaceholder, modelValue: "" }, + }); + + const searchBox = getByRole("searchbox"); + await expect.element(searchBox).toBeInTheDocument(); + await expect.element(searchBox) + .toHaveAttribute("placeholder", customPlaceholder); +}); + +test("empty search disabled clear button", async () => { + const { getByRole } = render(SearchField, { props: { modelValue: "" } }); + + const button = getByRole("button", { name: "clear" }); + await expect.element(button).toBeInTheDocument(); + await expect.element(button).toBeDisabled(); +}); + +test("non-empty search enabled clear button", async () => { + const { getByRole } = render(SearchField, { props: { modelValue: "hello" } }); + + const button = getByRole("button", { name: "clear" }); + await expect.element(button).toBeInTheDocument(); + await expect.element(button).toBeEnabled(); +}); + +test("filling search enables clear button", async () => { + const { getByRole } = render(SearchField, { props: { modelValue: "" } }); + const searchBox = getByRole("searchbox"); + const button = getByRole("button", { name: "clear" }); + + // simulate user typing into the input + await searchBox.fill("abc"); + + await expect.element(button).toBeEnabled(); +}); + +test("clear button clears model value", async () => { + const { getByRole } = render(SearchField, { props: { modelValue: "hello" } }); + const searchBox = getByRole("searchbox"); + const button = getByRole("button", { name: "clear" }); + + // ensure initial state + await expect.element(searchBox).toBeInTheDocument(); + await expect.element(button).toBeEnabled(); + + // click the clear button and assert the input was cleared + await button.click(); + + // input should reflect cleared model + await expect.element(searchBox).toHaveValue(""); + await expect.element(button).toBeDisabled(); +}); diff --git a/Frontend/tsconfig.app.json b/Frontend/tsconfig.app.json new file mode 100644 index 0000000..6bc4512 --- /dev/null +++ b/Frontend/tsconfig.app.json @@ -0,0 +1,35 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": [ + "vite/client" + ], + + /* Linting */ + "strict": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + + /* aliasing */ + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ], + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.vue", + "tests/**/*.spec.ts", + "tests/**/*.test.ts" + ] +} \ No newline at end of file diff --git a/Frontend/tsconfig.json b/Frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/Frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/Frontend/tsconfig.node.json b/Frontend/tsconfig.node.json new file mode 100644 index 0000000..940ad44 --- /dev/null +++ b/Frontend/tsconfig.node.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + }, + "include": ["vite.config.ts"] +} diff --git a/Frontend/vite.config.ts b/Frontend/vite.config.ts new file mode 100644 index 0000000..58df97f --- /dev/null +++ b/Frontend/vite.config.ts @@ -0,0 +1,67 @@ +/// + +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import vueDevTools from "vite-plugin-vue-devtools"; +import svgLoader from "vite-svg-loader"; +import tailwindcss from "@tailwindcss/vite"; +import { playwright } from "@vitest/browser-playwright"; +import { resolve } from "node:path"; + +// https://vite.dev/config/ +export default defineConfig({ + server: { + host: "0.0.0.0", + }, + plugins: [ + vue(), + vueDevTools(), + tailwindcss(), + svgLoader({ + svgoConfig: { + multipass: true, + plugins: [ + { + name: "preset-default", + params: { + overrides: { + // @see https://github.com/svg/svgo/issues/1128 + removeViewBox: false, + }, + }, + }, + ], + }, + }), + ], + resolve: { + alias: { + "@": resolve(__dirname, "./src/"), + }, + }, + base: "/muzika-gromche", + css: { + modules: { + localsConvention: "camelCaseOnly", + }, + }, + test: { + globals: true, + // environment: 'jsdom', + // include tests in `tests/` directory + include: [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "tests/**/*.spec.ts", + "tests/**/*.test.ts", + ], + browser: { + enabled: true, + provider: playwright(), + // https://vitest.dev/config/browser/playwright + instances: [ + { browser: "firefox" }, + ], + }, + }, +});