Compare commits
155 Commits
master
...
work/front
| Author | SHA1 | Date |
|---|---|---|
|
|
81870ecd47 | |
|
|
8a24448cb6 | |
|
|
a74bbfaee2 | |
|
|
ad0a20cc7e | |
|
|
51e578f2da | |
|
|
3563fa2b36 | |
|
|
f790decc4d | |
|
|
5a8f0201a3 | |
|
|
825355dd54 | |
|
|
c62535841a | |
|
|
b0d96ff67e | |
|
|
3607ccc92f | |
|
|
8570505758 | |
|
|
049a14e440 | |
|
|
1ec8275831 | |
|
|
9efe6adaf3 | |
|
|
a5b117e26d | |
|
|
01332ab77f | |
|
|
7aa3570b33 | |
|
|
e7866fda55 | |
|
|
cd9e0a7a10 | |
|
|
8223425b19 | |
|
|
9bf3a80341 | |
|
|
72a8016ab5 | |
|
|
9619a75427 | |
|
|
ceaac4e01b | |
|
|
aea755361b | |
|
|
e67c72951e | |
|
|
0fadf50bf4 | |
|
|
585ef604ff | |
|
|
99babe8bdf | |
|
|
bbd9b0204f | |
|
|
70eabe75dd | |
|
|
63de62111f | |
|
|
4cc9713fa7 | |
|
|
8710df7525 | |
|
|
9d23fd5b95 | |
|
|
4516b853cd | |
|
|
b3767cbbf0 | |
|
|
327e606deb | |
|
|
70e45d5ba2 | |
|
|
d4d3e15de3 | |
|
|
525c0e108f | |
|
|
73ad702684 | |
|
|
e67de4556c | |
|
|
0b0383003f | |
|
|
9ed98197f8 | |
|
|
fe5752cbff | |
|
|
c6b128270f | |
|
|
852d866073 | |
|
|
6a9ea8d4af | |
|
|
42c6179ba5 | |
|
|
5649a18633 | |
|
|
47f984cd28 | |
|
|
fc3a62e511 | |
|
|
5f0c890682 | |
|
|
59a069f51b | |
|
|
df796965f2 | |
|
|
26f9d2cf9f | |
|
|
a950093f8e | |
|
|
8842005898 | |
|
|
b4ae4bad41 | |
|
|
69e64397a0 | |
|
|
3d0795f04d | |
|
|
4abd0fb612 | |
|
|
dd3c9647e3 | |
|
|
8b2f4428bb | |
|
|
0dca416958 | |
|
|
1aa8c1ddfa | |
|
|
75d0ee2c1d | |
|
|
2e938dfc8d | |
|
|
1ffdd5d97e | |
|
|
276fbbec22 | |
|
|
05749ff122 | |
|
|
f131ad7148 | |
|
|
f50989b5ae | |
|
|
72adb9e713 | |
|
|
76e9ca3595 | |
|
|
b6f2ca355b | |
|
|
78370da460 | |
|
|
4d84a2d001 | |
|
|
0eb02698eb | |
|
|
c7b67b9042 | |
|
|
f53f837e3f | |
|
|
86644388f3 | |
|
|
c0e7185321 | |
|
|
9062f386de | |
|
|
3a2eaad493 | |
|
|
b70e868ac4 | |
|
|
bacb9f07c7 | |
|
|
2a28a36a69 | |
|
|
841ccc74ed | |
|
|
8729515537 | |
|
|
991e2a56b7 | |
|
|
c689198588 | |
|
|
667368d719 | |
|
|
6a0be0d780 | |
|
|
0573091162 | |
|
|
2ef0fc3bd9 | |
|
|
ce437aa86c | |
|
|
7ed299ead8 | |
|
|
f959a4ebb2 | |
|
|
7a5013524d | |
|
|
14a57fcae7 | |
|
|
47876b18bf | |
|
|
5abad0b1ba | |
|
|
1cdbdf2f09 | |
|
|
45a73793fb | |
|
|
581d9701bd | |
|
|
49ac86e6f9 | |
|
|
0f8ab1a75b | |
|
|
dda00ce228 | |
|
|
39a8255532 | |
|
|
b7eb4ce60b | |
|
|
d6a2bf21b1 | |
|
|
730f125d62 | |
|
|
8e065d3e51 | |
|
|
2a33457661 | |
|
|
0fbf0b04f4 | |
|
|
0c5d4f7158 | |
|
|
9e066372c5 | |
|
|
ca977625db | |
|
|
7d1cac6e2e | |
|
|
2229fa3545 | |
|
|
118eecbb59 | |
|
|
b8824dbbfb | |
|
|
3e751c0d8d | |
|
|
601ecf8887 | |
|
|
d13c617895 | |
|
|
e1f19b3919 | |
|
|
ba0162b3e1 | |
|
|
ed8804b7a7 | |
|
|
9be9eaaf80 | |
|
|
0683a18491 | |
|
|
6204888453 | |
|
|
c15637b347 | |
|
|
42c1f29a16 | |
|
|
8a193fa408 | |
|
|
4ee20adea7 | |
|
|
2df7d28d43 | |
|
|
43d1565dbe | |
|
|
f5dab20d67 | |
|
|
38cfb5f5e7 | |
|
|
b86c50a848 | |
|
|
694bc61dae | |
|
|
909efa720f | |
|
|
a8761bf679 | |
|
|
ad77530b6d | |
|
|
34d8da1562 | |
|
|
b73c7ee3cb | |
|
|
0d4f180a37 | |
|
|
829c44e347 | |
|
|
b15e93ac34 | |
|
|
f158e7728c | |
|
|
2b42899779 |
|
|
@ -0,0 +1,5 @@
|
||||||
|
[*.cs]
|
||||||
|
|
||||||
|
# IDE0290: Use primary constructor
|
||||||
|
# Primary constructors are far from perfect: they can't have readonly fields, while fields can be used anywhere in the class body.
|
||||||
|
csharp_style_prefer_primary_constructors = false
|
||||||
|
|
@ -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 <track_name>` and `just ogg1 <track_name>` — custom msbuild targets that convert wav→ogg via `dotnet msbuild /t:wav2ogg` (used when adding tracks from outside of this repo).
|
||||||
|
- `just loud <track_name>` — 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 <track_name>` and `just oggloud <track_name>` — 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/<Configuration>/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.
|
||||||
BIN
Assets/DeployDestroyLoop.mp3 (Stored with Git LFS)
BIN
Assets/DeployDestroyStart.mp3 (Stored with Git LFS)
BIN
Assets/DurochkaLoop.mp3 (Stored with Git LFS)
BIN
Assets/DurochkaStart.mp3 (Stored with Git LFS)
BIN
Assets/GorgorodLoop.mp3 (Stored with Git LFS)
BIN
Assets/GorgorodStart.mp3 (Stored with Git LFS)
BIN
Assets/MoyaZhittyaLoop.mp3 (Stored with Git LFS)
BIN
Assets/MoyaZhittyaStart.mp3 (Stored with Git LFS)
BIN
Assets/MuzikaGromcheLoop.mp3 (Stored with Git LFS)
BIN
Assets/MuzikaGromcheStart.mp3 (Stored with Git LFS)
BIN
Assets/VseVZaleLoop.mp3 (Stored with Git LFS)
BIN
Assets/VseVZaleStart.mp3 (Stored with Git LFS)
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.9001.2
|
||||||
|
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.9001.1 - v73 Music louder Edition
|
||||||
|
|
||||||
|
- Raised the default audio volume, and added a configuration slider.
|
||||||
|
- Tweaked color palette, lyrics and visual effects for MoyaZhittya and some other tracks.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.9001.0 - v73 Music quieter Edition
|
||||||
|
|
||||||
|
- Updated netcode-patch to support Lethal Company v73.
|
||||||
|
- Remastered all the audio tracks to target a consistent loudness level which allows you hear your teammates.
|
||||||
|
- Remastered track Song2 to fix cut points.
|
||||||
|
- Shortened intro of track Peretasovka to match vanilla timings.
|
||||||
|
- Added multiple intro variants for BeefLiver.
|
||||||
|
- Added a new track BbIXODaHET.
|
||||||
|
- Added a new track Whistle. Now it can fully replace WhistleJester!
|
||||||
|
- Added a new track ReelGoon.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.420.9004 - Life Support Edition
|
||||||
|
|
||||||
|
- Override Death Screen / Game Over text in certain cases.
|
||||||
|
- Added a new track AttentionPls featuring multiple intro variants and new visual effects.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.420.9003 - Lights Out Edition
|
||||||
|
|
||||||
|
- Fixed wrong colors during fade out transition, e.g. in Mineshaft tunnel tiles.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.420.9002 - Anime Edition
|
||||||
|
|
||||||
|
- Added a new track OnePartiyaUdar in Japanese language.
|
||||||
|
- Remastered recently added tracks at conventional 44100 Hz for better stitching.
|
||||||
|
- Improved playback experience: use precise DSP time and up-front scheduing for seamless audio stitching, add custom Audio Sources to improve reliability.
|
||||||
|
- Removed remaining CSync code and package references even from debug builds.
|
||||||
|
- Downgraded LobbyCompatibility to optional dependency.
|
||||||
|
- Toggled config option to increase certain spawn rate to ON by default.
|
||||||
|
- Fixed resetting to wrong initial colors, e.g. in Mineshaft tunnel tiles.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.420.9001 - Multiverse Edition
|
||||||
|
|
||||||
|
- Added support for tracks to rotate between multiple audio variants during a round.
|
||||||
|
- Added a new track Beha with three different variants of intro.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.420.69 - It's All DiscoNnected Edition
|
||||||
|
|
||||||
|
- Fixed harmless but annoying errors in BepInEx console output.
|
||||||
|
- Improve smoothness of color animations.
|
||||||
|
- Added a new track BeefLiver.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.69.420 - It's All Connected Edition
|
||||||
|
|
||||||
|
- Fix certain object hanging around after being disabled.
|
||||||
|
- CSync proved to be unreliable for config syncing, so rewrote track selection to custom netcode.
|
||||||
|
|
||||||
|
## MuzikaGromche 13.37.9001 - Chromaberrated Edition
|
||||||
|
|
||||||
|
- Fixed more missing flickering behaviours for some animators controllers.
|
||||||
|
- Fixed some powered lights not fully turning off or flickering when there are multiple Light components per container.
|
||||||
|
- Improved performance by pre-loading certain assets at the start of round instead of at a timing-critical frame update.
|
||||||
|
- Added an opt-in config option to increase certain spawn rate to experience content of this mod more often.
|
||||||
|
|
||||||
|
## MuzikaGromche 13.37.1337 - Photosensitivity Warning Edition
|
||||||
|
|
||||||
|
- Added LobbyCompatibility to dependencies to avoid desync issues.
|
||||||
|
- Fixed lyrics not being displayed in some situations.
|
||||||
|
- Fixed visual issues with the fade out effect.
|
||||||
|
- Fixed visual glitch at the last beat of a loop.
|
||||||
|
- Fixed timings of one of the tracks.
|
||||||
|
- Removed unnecessary "Enable Color Animations" config option.
|
||||||
|
- Fixed missing flickering behaviours for some animators controllers.
|
||||||
|
|
||||||
|
## MuzikaGromche 13.37.911 - Sri Lanka Bus hotfix
|
||||||
|
|
||||||
|
- Fixed certain event sometimes not working due to wrong method call.
|
||||||
|
- Added support for pre-v70 Mansion Main tile.
|
||||||
|
|
||||||
|
## MuzikaGromche 13.37.420 - Sri Lanka Bus Edition
|
||||||
|
|
||||||
|
Completely rewritten by Ratijas, with tons of new content.
|
||||||
|
|
||||||
|
- Added lots of new tracks.
|
||||||
|
- Fixed gaps in old tracks.
|
||||||
|
- New code synchronizes light show to the beat.
|
||||||
|
- Timings, animation curves, color palettes and events fine-tuned for each track by visual artist [Just Nothing](https://t.me/REALJUSTNOTHING).
|
||||||
|
- Configurable Audio Delay for those with Bluetooth headset.
|
||||||
|
- Configurable chance of randomly choosing each tracks.
|
||||||
|
- Added lyrics to *some* of the tracks, and a configuration toggle.
|
||||||
|
- Certain tiles are patched by [WaterGun](https://www.youtube.com/channel/UCCxCFfmrnqkFZ8i9FsXBJVA) to add some visual flare.
|
||||||
|
|
||||||
|
## MuzikaGromche 13.37.6 - Christmas Special
|
||||||
|
|
||||||
|
Last known version released by Oflor. Added special timed content for New Year and Christmas.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<Project>
|
||||||
|
<Target Name="NetcodePatch" AfterTargets="PostBuildEvent">
|
||||||
|
<Exec Command="dotnet netcode-patch -uv 2022.3.62 -nv 1.12.0 "$(TargetPath)" @(ReferencePathWithRefAssemblies->'"%(Identity)"', ' ')"/>
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
|
|
@ -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/` to root@ratijas.me `/var/www/html/muzika-gromche/`.
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" href="/icon-32.png" sizes="32x32" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Muzika Gromche — The ultimate Jester party music mod</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"name": "muzika-gromche-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/generate-icons.js",
|
||||||
|
"build": "npm run prebuild && 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.17",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"@vitest/browser-playwright": "^4.0.15",
|
||||||
|
"@vitest/coverage-v8": "4.0.14",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"eslint": "~9.39.1",
|
||||||
|
"eslint-plugin-vue": "~10.5.1",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.1.14",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
|
"vite-svg-loader": "^5.1.0",
|
||||||
|
"vitest": "^4.0.15",
|
||||||
|
"vitest-browser-vue": "^2.0.1",
|
||||||
|
"vue-tsc": "^3.1.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.1.14"
|
||||||
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"core-js",
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Copy /Assets/ to this directory.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -0,0 +1,50 @@
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import toIco from 'png-to-ico';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const sourceIcon = path.resolve(__dirname, '../../icon.png');
|
||||||
|
const outputDir = path.resolve(__dirname, '../public');
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate PNGs
|
||||||
|
const sizes = [32, 192, 256];
|
||||||
|
for (const size of sizes) {
|
||||||
|
const outputPath = path.join(outputDir, `icon-${size}.png`);
|
||||||
|
await sharp(sourceIcon)
|
||||||
|
.resize(size, size)
|
||||||
|
.toFile(outputPath);
|
||||||
|
console.log(`Generated ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate apple-touch-icon
|
||||||
|
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png');
|
||||||
|
await sharp(sourceIcon)
|
||||||
|
.resize(180, 180)
|
||||||
|
.toFile(appleIconPath);
|
||||||
|
console.log(`Generated ${appleIconPath}`);
|
||||||
|
|
||||||
|
// Generate favicon.ico
|
||||||
|
const icoSizes = [16, 24, 32, 48];
|
||||||
|
const buffers = await Promise.all(icoSizes.map(size =>
|
||||||
|
sharp(sourceIcon)
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toBuffer()
|
||||||
|
));
|
||||||
|
const icoBuffer = await toIco(buffers);
|
||||||
|
const icoPath = path.join(outputDir, 'favicon.ico');
|
||||||
|
await fs.writeFile(icoPath, icoBuffer);
|
||||||
|
console.log(`Generated ${icoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIcons().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import GlobalHeader from '@/components/GlobalHeader.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tw:h-full tw:flex tw:flex-col">
|
||||||
|
<GlobalHeader class="tw:flex-none" />
|
||||||
|
<main class="tw:flex-auto tw:overflow-hidden">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
After Width: | Height: | Size: 124 B |
|
After Width: | Height: | Size: 426 B |
|
|
@ -0,0 +1,533 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type AudioTrack, useWrapTime, wrapTimeFn } from "@/lib/AudioTrack";
|
||||||
|
import type { Seconds } from "@/lib/units";
|
||||||
|
import {
|
||||||
|
type ConfigurableWindow,
|
||||||
|
tryOnScopeDispose,
|
||||||
|
useRafFn,
|
||||||
|
useThrottleFn,
|
||||||
|
watchImmediate,
|
||||||
|
} from "@vueuse/core";
|
||||||
|
import {
|
||||||
|
type MaybeRefOrGetter,
|
||||||
|
type Ref,
|
||||||
|
shallowRef,
|
||||||
|
toValue,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
export const VOLUME_MAX: number = 1.5;
|
||||||
|
|
||||||
|
interface PlayerHandle {
|
||||||
|
/**
|
||||||
|
* The `stop()` method schedules a sound to cease playback at the specified time.
|
||||||
|
*/
|
||||||
|
stop: (when?: Seconds) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioTrackBuffersHandle extends PlayerHandle {
|
||||||
|
/**
|
||||||
|
* Time in AudioContext coordinate system of a moment which lines up with the start of the intro audio buffer.
|
||||||
|
* If the startPosition was greater than zero, this time is already in the past when the function returns.
|
||||||
|
*/
|
||||||
|
readonly introStartTime: Seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playing intro + loop buffers at given position.
|
||||||
|
*
|
||||||
|
* @returns Handle with introStartTime and stop() method.
|
||||||
|
*/
|
||||||
|
function playAudioTrackBuffers(
|
||||||
|
audioCtx: AudioContext,
|
||||||
|
destinationNode: AudioNode,
|
||||||
|
audioTrack: AudioTrack,
|
||||||
|
/**
|
||||||
|
* Position in seconds from the start of the intro
|
||||||
|
*/
|
||||||
|
startPosition: Seconds = 0,
|
||||||
|
): AudioTrackBuffersHandle {
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
|
||||||
|
const introBuffer = audioTrack.loadedIntro!;
|
||||||
|
const loopBuffer = audioTrack.loadedLoop!;
|
||||||
|
|
||||||
|
const introDuration = introBuffer.duration;
|
||||||
|
const loopDuration = loopBuffer.duration;
|
||||||
|
|
||||||
|
const wrapper = wrapTimeFn(audioTrack);
|
||||||
|
startPosition = wrapper(startPosition);
|
||||||
|
|
||||||
|
let currentIntro: AudioBufferSourceNode | null;
|
||||||
|
let currentLoop: AudioBufferSourceNode | null;
|
||||||
|
let introStartTime: Seconds;
|
||||||
|
|
||||||
|
// figure out where to start
|
||||||
|
if (startPosition < introDuration) {
|
||||||
|
// start intro with offset, schedule loop after remaining intro time
|
||||||
|
const introOffset = startPosition;
|
||||||
|
const timeUntilLoop = introDuration - introOffset;
|
||||||
|
|
||||||
|
const introNode = audioCtx.createBufferSource();
|
||||||
|
introNode.buffer = introBuffer;
|
||||||
|
introNode.connect(destinationNode);
|
||||||
|
introNode.start(now, introOffset);
|
||||||
|
|
||||||
|
const loopNode = audioCtx.createBufferSource();
|
||||||
|
loopNode.buffer = loopBuffer;
|
||||||
|
loopNode.loop = true;
|
||||||
|
loopNode.connect(destinationNode);
|
||||||
|
loopNode.start(now + timeUntilLoop, 0);
|
||||||
|
|
||||||
|
currentIntro = introNode;
|
||||||
|
currentLoop = loopNode;
|
||||||
|
introStartTime = now - startPosition;
|
||||||
|
} else {
|
||||||
|
// start directly in loop with proper offset into loop
|
||||||
|
const loopOffset = (startPosition - introDuration) % loopDuration;
|
||||||
|
const loopNode = audioCtx.createBufferSource();
|
||||||
|
loopNode.buffer = loopBuffer;
|
||||||
|
loopNode.loop = true;
|
||||||
|
loopNode.connect(destinationNode);
|
||||||
|
loopNode.start(now, loopOffset);
|
||||||
|
|
||||||
|
currentIntro = null;
|
||||||
|
currentLoop = loopNode;
|
||||||
|
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
|
||||||
|
// introStartTime = now - introDuration - loopOffset;
|
||||||
|
introStartTime = now - startPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(when?: Seconds) {
|
||||||
|
try {
|
||||||
|
currentIntro?.stop(when);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
currentLoop?.stop(when);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
currentIntro = null;
|
||||||
|
currentLoop = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { introStartTime, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
|
||||||
|
playerResult: Omit<T, "stop">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 25 ms for fade-in/fade-out
|
||||||
|
*/
|
||||||
|
const DEFAULT_FADE_DURATION = 0.025;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the given player function with a Gain node. Applies fade in effect on start and fade out on stop.
|
||||||
|
*
|
||||||
|
* @returns Handle with introStartTime and stop() method.
|
||||||
|
*/
|
||||||
|
function playWithFadeInOut<T extends PlayerHandle>(
|
||||||
|
audioCtx: AudioContext,
|
||||||
|
destinationNode: AudioNode,
|
||||||
|
player: (destinationNode: AudioNode) => T,
|
||||||
|
/**
|
||||||
|
* Duration of fade in/out in seconds. Fade out extends past the stop() call.
|
||||||
|
*/
|
||||||
|
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
|
||||||
|
): PlayWithFadeInOut<T> {
|
||||||
|
const GAIN_MIN = 0.0001;
|
||||||
|
const GAIN_MAX = 1.0;
|
||||||
|
|
||||||
|
const fadeGain = audioCtx.createGain();
|
||||||
|
fadeGain.connect(destinationNode);
|
||||||
|
fadeGain.gain.value = GAIN_MIN;
|
||||||
|
|
||||||
|
const playerHandle = player(fadeGain);
|
||||||
|
|
||||||
|
// fade in
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
const fadeEnd = now + fadeDuration;
|
||||||
|
fadeGain.gain.setValueAtTime(GAIN_MIN, now);
|
||||||
|
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd);
|
||||||
|
|
||||||
|
// TODO: setTimeout to actually stop after `when`?
|
||||||
|
function stop(_when?: Seconds) {
|
||||||
|
// fade out
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
const fadeEnd = now + fadeDuration;
|
||||||
|
fadeGain.gain.cancelScheduledValues(now);
|
||||||
|
fadeGain.gain.setValueAtTime(GAIN_MAX, now);
|
||||||
|
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd);
|
||||||
|
|
||||||
|
playerHandle.stop(fadeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { playerResult: playerHandle, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties relates to the state of playback.
|
||||||
|
*/
|
||||||
|
export interface PlaybackState {
|
||||||
|
/**
|
||||||
|
* Readonly reference to whether audio is currently playing.
|
||||||
|
*/
|
||||||
|
readonly isPlaying: Readonly<Ref<boolean>>;
|
||||||
|
/**
|
||||||
|
* Readonly reference to the last remembered start-of-playback position.
|
||||||
|
*
|
||||||
|
* Will only update if stop(rememberPosition=true) or seek() is called.
|
||||||
|
*/
|
||||||
|
readonly startPosition: Readonly<Ref<Seconds>>;
|
||||||
|
/**
|
||||||
|
* Returns current playback position in seconds based on AudioContext time.
|
||||||
|
*
|
||||||
|
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
|
||||||
|
*/
|
||||||
|
getCurrentPosition(): Seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StopOptions {
|
||||||
|
/**
|
||||||
|
* If true, update remembered playback position to current position, otherwise revert to last remembered one.
|
||||||
|
*
|
||||||
|
* Defaults to false.
|
||||||
|
*/
|
||||||
|
rememberPosition?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekOptions {
|
||||||
|
/**
|
||||||
|
* If scrub is requested, plays a short sample at that position.
|
||||||
|
*
|
||||||
|
* Defaults to false.
|
||||||
|
*/
|
||||||
|
scrub?: boolean;
|
||||||
|
// TODO: optionally keep playing after seeking?
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player controls and properties relates to the state of playback.
|
||||||
|
*/
|
||||||
|
export interface PlayerControls {
|
||||||
|
/**
|
||||||
|
* Start playing audio buffers from the last remembered position.
|
||||||
|
*/
|
||||||
|
play: () => void;
|
||||||
|
/**
|
||||||
|
* Stop playing audio buffers.
|
||||||
|
*
|
||||||
|
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
|
||||||
|
*/
|
||||||
|
stop: (options?: StopOptions) => void;
|
||||||
|
/**
|
||||||
|
* Seek to given position in seconds.
|
||||||
|
*
|
||||||
|
* - Stop the playback.
|
||||||
|
* - If scrub is requested, plays a short sample at that position.
|
||||||
|
*/
|
||||||
|
seek: (position: Seconds, options?: SeekOptions) => void;
|
||||||
|
/**
|
||||||
|
* Properties relates to the state of playback.
|
||||||
|
*/
|
||||||
|
readonly playback: PlaybackState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
|
||||||
|
}
|
||||||
|
|
||||||
|
function reusableAudioBuffersTrackPlayer(
|
||||||
|
audioCtx: AudioContext,
|
||||||
|
destinationNode: AudioNode,
|
||||||
|
audioTrack: AudioTrack,
|
||||||
|
): ReusableAudioBuffersTrackPlayer {
|
||||||
|
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null;
|
||||||
|
const isPlaying = shallowRef(false);
|
||||||
|
const wrapper = wrapTimeFn(audioTrack);
|
||||||
|
const startPosition = useWrapTime(audioTrack, 0);
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (currentHandle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentHandle = playWithFadeInOut(
|
||||||
|
audioCtx,
|
||||||
|
destinationNode,
|
||||||
|
(destinationNode) =>
|
||||||
|
playAudioTrackBuffers(
|
||||||
|
audioCtx,
|
||||||
|
destinationNode,
|
||||||
|
audioTrack,
|
||||||
|
startPosition.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
isPlaying.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(options?: { rememberPosition?: boolean }) {
|
||||||
|
const {
|
||||||
|
rememberPosition = false,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
|
if (currentHandle) {
|
||||||
|
isPlaying.value = false;
|
||||||
|
|
||||||
|
if (rememberPosition) {
|
||||||
|
startPosition.value = getCurrentPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop and discard current handle
|
||||||
|
currentHandle.stop();
|
||||||
|
currentHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(seekPosition: Seconds, options?: SeekOptions) {
|
||||||
|
const {
|
||||||
|
scrub = false,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
|
stop({ rememberPosition: false });
|
||||||
|
|
||||||
|
startPosition.value = seekPosition;
|
||||||
|
|
||||||
|
if (scrub) {
|
||||||
|
doThrottledScrub();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub is subject to debouncing/throttling, so it doesn't start
|
||||||
|
// playing samples too often before previous ones could stop.
|
||||||
|
const doThrottledScrub = useThrottleFn(() => {
|
||||||
|
// play a short sample at the seeked position
|
||||||
|
const scrubHandle = playWithFadeInOut(
|
||||||
|
audioCtx,
|
||||||
|
destinationNode,
|
||||||
|
(destinationNode) =>
|
||||||
|
playAudioTrackBuffers(
|
||||||
|
audioCtx,
|
||||||
|
destinationNode,
|
||||||
|
audioTrack,
|
||||||
|
startPosition.value,
|
||||||
|
),
|
||||||
|
0.01, // short fade of 10 ms
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
scrubHandle.stop(0.01);
|
||||||
|
}, 80); // stop after N ms
|
||||||
|
}, 80);
|
||||||
|
|
||||||
|
function getCurrentPosition(): Seconds {
|
||||||
|
if (!currentHandle) {
|
||||||
|
return startPosition.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = audioCtx.currentTime -
|
||||||
|
currentHandle.playerResult.introStartTime;
|
||||||
|
|
||||||
|
return wrapper(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
seek,
|
||||||
|
playback: {
|
||||||
|
isPlaying,
|
||||||
|
startPosition,
|
||||||
|
getCurrentPosition,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LivePlaybackPositionOptions extends ConfigurableWindow {
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LivePlaybackPositionReturn {
|
||||||
|
stop: () => void;
|
||||||
|
position: Readonly<Ref<Seconds>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLivePlaybackPosition(
|
||||||
|
playback: MaybeRefOrGetter<PlaybackState | null>,
|
||||||
|
options?: LivePlaybackPositionOptions,
|
||||||
|
): LivePlaybackPositionReturn {
|
||||||
|
const cleanups: Function[] = [];
|
||||||
|
const cleanup = () => {
|
||||||
|
cleanups.forEach((fn) => fn());
|
||||||
|
cleanups.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPosition = () => {
|
||||||
|
return toValue(playback)?.getCurrentPosition() ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const position = shallowRef<Seconds>(getPosition());
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
position.value = getPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const raf = useRafFn(() => {
|
||||||
|
updatePosition();
|
||||||
|
}, {
|
||||||
|
...options,
|
||||||
|
immediate: false,
|
||||||
|
once: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopWatch = watchImmediate(() => [
|
||||||
|
toValue(playback),
|
||||||
|
], ([playback]) => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
if (!playback) return;
|
||||||
|
|
||||||
|
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
|
||||||
|
if (isPlaying) {
|
||||||
|
raf.resume();
|
||||||
|
} else {
|
||||||
|
raf.pause();
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
cleanups.push(watch(playback.startPosition, () => {
|
||||||
|
raf.pause();
|
||||||
|
updatePosition();
|
||||||
|
if (playback.isPlaying.value) {
|
||||||
|
raf.resume();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
cleanups.push(() => raf.pause());
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopWatch();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(cleanup);
|
||||||
|
|
||||||
|
return { stop, position };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePlayStop(
|
||||||
|
player: PlayerControls | null,
|
||||||
|
options?: StopOptions,
|
||||||
|
) {
|
||||||
|
if (!player) return;
|
||||||
|
if (player.playback.isPlaying.value) {
|
||||||
|
player.stop(options);
|
||||||
|
} else {
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, AudioBuffer>();
|
||||||
|
|
||||||
|
private _player: Ref<PlayerControls | null> = shallowRef(null);
|
||||||
|
// readonly player: Readonly<Ref<PlayerControls | null>> = this._player;
|
||||||
|
|
||||||
|
// settings
|
||||||
|
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.audioCtx) return;
|
||||||
|
this.audioCtx =
|
||||||
|
new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
|
||||||
|
this.masterGain = this.audioCtx.createGain();
|
||||||
|
|
||||||
|
// routing: sources -> fadeGain -> masterGain -> destination
|
||||||
|
this.masterGain.connect(this.audioCtx.destination);
|
||||||
|
// default full volume
|
||||||
|
this.masterGain.gain.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
this.stopPlayer();
|
||||||
|
this.audioCtx?.close();
|
||||||
|
this.audioCtx = null;
|
||||||
|
this.masterGain = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAudioBuffer(
|
||||||
|
url: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AudioBuffer> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
initPlayer(
|
||||||
|
audioTrack: AudioTrack,
|
||||||
|
): PlayerControls | null {
|
||||||
|
this.init();
|
||||||
|
if (!this.audioCtx || !this.masterGain) return null;
|
||||||
|
|
||||||
|
this.stopPlayer();
|
||||||
|
|
||||||
|
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) return null;
|
||||||
|
|
||||||
|
const player = reusableAudioBuffersTrackPlayer(
|
||||||
|
this.audioCtx,
|
||||||
|
this.masterGain,
|
||||||
|
audioTrack,
|
||||||
|
);
|
||||||
|
this._player.value = player;
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPlayer() {
|
||||||
|
if (this._player.value) {
|
||||||
|
this._player.value.stop();
|
||||||
|
this._player.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioEngine = new AudioEngine();
|
||||||
|
export default audioEngine;
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import type { Px } from "@/lib/units";
|
||||||
|
import { useWeakCache } from "@/lib/useWeakCache";
|
||||||
|
import { type Fn, tryOnScopeDispose, watchImmediate } from "@vueuse/core";
|
||||||
|
import type { MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, shallowRef, toValue, triggerRef } from "vue";
|
||||||
|
|
||||||
|
// Result of async computation
|
||||||
|
interface UseWaveform {
|
||||||
|
readonly isDone: Readonly<Ref<boolean>>;
|
||||||
|
readonly peaks: Readonly<Ref<Float32Array>>;
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaveformComputation {
|
||||||
|
readonly isDone: Readonly<Ref<boolean>>;
|
||||||
|
readonly peaks: Readonly<Ref<Float32Array>>;
|
||||||
|
/** Start or continue asynchronous computation. */
|
||||||
|
run: () => void;
|
||||||
|
/** Stops any ongoing asynchronous computation. */
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const WAVEFORM_MIN_WIDTH = 10;
|
||||||
|
|
||||||
|
const emptyComputation: WaveformComputation = {
|
||||||
|
isDone: shallowRef(false),
|
||||||
|
peaks: shallowRef(new Float32Array(0)),
|
||||||
|
run() {},
|
||||||
|
stop() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWaveform(
|
||||||
|
buffer: MaybeRefOrGetter<AudioBuffer>,
|
||||||
|
width: MaybeRefOrGetter<Px>,
|
||||||
|
): UseWaveform {
|
||||||
|
const cleanups: Fn[] = [];
|
||||||
|
const cleanup = () => {
|
||||||
|
cleanups.forEach((fn) => fn());
|
||||||
|
cleanups.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation);
|
||||||
|
|
||||||
|
const stopWatch = watchImmediate(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
toValue(buffer),
|
||||||
|
toValue(width),
|
||||||
|
] as const,
|
||||||
|
([b, w]) => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const map = waveformsCache.getOrNew(b);
|
||||||
|
|
||||||
|
if (w < WAVEFORM_MIN_WIDTH) {
|
||||||
|
compRef.value = emptyComputation;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let comp = map.get(w);
|
||||||
|
if (!comp) {
|
||||||
|
comp = useWaveformComputation(b, w);
|
||||||
|
map.set(w, comp);
|
||||||
|
}
|
||||||
|
compRef.value = comp;
|
||||||
|
comp.run();
|
||||||
|
cleanups.push(() => {
|
||||||
|
compRef.value = emptyComputation;
|
||||||
|
comp.stop();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopWatch();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(stop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDone: computed(() => compRef.value.isDone.value),
|
||||||
|
peaks: computed(() => compRef.value.peaks.value),
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const useWaveformComputation = (
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
width: Px,
|
||||||
|
): WaveformComputation => {
|
||||||
|
// How many times run() has been called without stop().
|
||||||
|
// This whole computation should not stop until there is at least one user out there.
|
||||||
|
let users = 0;
|
||||||
|
|
||||||
|
// How many pixels of `width` have been processed so far
|
||||||
|
let progress = 0;
|
||||||
|
|
||||||
|
// Waveform data, length shall be equal to the requested width
|
||||||
|
const waveform = new Float32Array(width);
|
||||||
|
|
||||||
|
const isDone = shallowRef(false);
|
||||||
|
const peaks = shallowRef(waveform);
|
||||||
|
|
||||||
|
const nChannels = buffer.numberOfChannels;
|
||||||
|
|
||||||
|
const samplesPerPx = buffer.length / width;
|
||||||
|
const blocksPerChannel: Float32Array<ArrayBuffer>[] = [];
|
||||||
|
for (let channel = 0; channel < nChannels; channel++) {
|
||||||
|
blocksPerChannel[channel] = new Float32Array(Math.ceil(samplesPerPx));
|
||||||
|
}
|
||||||
|
|
||||||
|
const areWeDoneYet = () => progress >= width;
|
||||||
|
|
||||||
|
function stepBlock() {
|
||||||
|
const blockStart = Math.floor(progress * samplesPerPx);
|
||||||
|
const blockEnd = Math.floor((progress + 1) * samplesPerPx);
|
||||||
|
const blockSize = blockEnd - blockStart;
|
||||||
|
|
||||||
|
for (let channel = 0; channel < nChannels; channel++) {
|
||||||
|
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
waveform[progress] = compressBlock(blocksPerChannel, blockSize);
|
||||||
|
progress += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBatchOfBlocks() {
|
||||||
|
// run blocks for up to ~10ms to keep UI responsive
|
||||||
|
const start = performance.now();
|
||||||
|
const progressStart = progress;
|
||||||
|
while (!areWeDoneYet()) {
|
||||||
|
stepBlock();
|
||||||
|
if (performance.now() - start >= 10 || progress - progressStart > 100) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerRef(peaks);
|
||||||
|
// triggerRef may as well not trigger refs
|
||||||
|
// https://github.com/vuejs/core/issues/9579
|
||||||
|
// Combined with a throttled drawing function,
|
||||||
|
// this is a slightly better-than-worse workaround.
|
||||||
|
peaks.value = new Float32Array(0);
|
||||||
|
peaks.value = waveform;
|
||||||
|
|
||||||
|
if (areWeDoneYet()) {
|
||||||
|
isDone.value = true;
|
||||||
|
timeoutID = NaN;
|
||||||
|
} else {
|
||||||
|
timeoutID = setTimeout(stepBatchOfBlocks, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutID: number = NaN;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDone,
|
||||||
|
peaks,
|
||||||
|
run() {
|
||||||
|
users += 1;
|
||||||
|
|
||||||
|
if (Number.isNaN(timeoutID) && users === 1) {
|
||||||
|
timeoutID = setTimeout(stepBatchOfBlocks, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
users -= 1;
|
||||||
|
|
||||||
|
if (!Number.isNaN(timeoutID) && users === 0) {
|
||||||
|
window.clearTimeout(timeoutID);
|
||||||
|
timeoutID = NaN;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function compressBlock(channels: Float32Array[], blockSize: number): number {
|
||||||
|
let peak = 0.0;
|
||||||
|
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
for (let channel = 0; channel < channels.length; channel++) {
|
||||||
|
peak = Math.max(peak, Math.abs(channels[channel]![i]!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
} = defineProps<{
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tw:h-full tw:flex tw:flex-col">
|
||||||
|
<div class="tw:w-full tw:max-w-2xl tw:self-center">
|
||||||
|
<h1 class="tw:text-4xl tw:p-8">{{ title }}</h1>
|
||||||
|
<p class="tw:p-4">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
// import OpenInNew from '@material-design-icons/svg/outlined/open_in_new.svg?url&inline';
|
||||||
|
// import OpenInNew2 from '@material-design-icons/svg/outlined/open_in_new.svg';
|
||||||
|
|
||||||
|
const trackStore = useTrackStore();
|
||||||
|
const { version } = storeToRefs(trackStore);
|
||||||
|
|
||||||
|
const product = "MuzikaGromche";
|
||||||
|
const productLink = "https://thunderstore.io/c/lethal-company/p/Ratijas/MuzikaGromche/";
|
||||||
|
const year = 2025;
|
||||||
|
const author = "Ratijas";
|
||||||
|
const authorLink = "https://ratijas.me";
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<footer>
|
||||||
|
<!-- TODO: A bug/omission somewhere in Vite/Rolldown/SVGO prevents Vite/SVGO plugin config from injecting { "fill": "currentColor" } into ?inline imported SVG. -->
|
||||||
|
<div
|
||||||
|
class="tw:py-2 tw:px-4 tw:gap-2 tw:flex tw:flex-row tw:max-sm:flex-col tw:flex-wrap tw:items-center tw:justify-center toolbar-background"
|
||||||
|
style="border-top: var(--view-separator-border);">
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
<a :href="productLink" target="_blank" rel="nofollow">{{ product }}</a>
|
||||||
|
</span>
|
||||||
|
<template v-if="version">
|
||||||
|
<span>
|
||||||
|
v{{ version }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<div class="separator tw:max-sm:hidden" />
|
||||||
|
<span>
|
||||||
|
Copyright © {{ year }}
|
||||||
|
</span>
|
||||||
|
<div class="separator tw:max-sm:hidden" />
|
||||||
|
<span>
|
||||||
|
Made by <a :href="authorLink" target="_blank" rel="nofollow">{{ author }}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.separator {
|
||||||
|
align-self: stretch;
|
||||||
|
max-height: 1em;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-left: 1.5px solid;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTitle, DEFAULT_TITLE } from '@/router';
|
||||||
|
import { useScrollStore } from '@/store/ScrollStore';
|
||||||
|
import ArrowBack from '@material-design-icons/svg/round/arrow_back_ios.svg';
|
||||||
|
import ArrowTop from '@material-design-icons/svg/round/keyboard_arrow_up.svg';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { RouterLink, useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const title = useTitle();
|
||||||
|
const route = useRoute();
|
||||||
|
const arrowBackVisible = computed(() => {
|
||||||
|
return route.path !== '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollStore = useScrollStore();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="tw:flex tw:flex-row tw:gap-4">
|
||||||
|
<RouterLink v-if="arrowBackVisible" to="/"
|
||||||
|
class="header-button-back tw:flex-none tw:aspect-square tw:max-w-10 tw:flex tw:place-items-center tw:justify-center">
|
||||||
|
<ArrowBack class="tw:fill-current tw:w-10 tw:h-10" />
|
||||||
|
</RouterLink>
|
||||||
|
<h1 class="tw:flex-1 tw:p-2 tw:text-4xl tw:text-center">
|
||||||
|
{{ title }}<span v-if="title !== DEFAULT_TITLE" class="tw:max-sm:hidden" > — {{ DEFAULT_TITLE }}</span>
|
||||||
|
</h1>
|
||||||
|
<button type="button" title="Scroll to Top"
|
||||||
|
class="tw:flex-none tw:aspect-square tw:max-w-10 tw:flex tw:place-items-center tw:justify-center tw:disabled:opacity-30 tw:transition-opacity"
|
||||||
|
:disabled="scrollStore.isAtTop" @click="scrollStore.scrollToTop">
|
||||||
|
<ArrowTop class="tw:fill-current tw:w-10 tw:h-10" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
header {
|
||||||
|
background-color: var(--header-background-color);
|
||||||
|
border-bottom: var(--view-separator-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button-back:any-link {
|
||||||
|
color: unset;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TransitionPresets, useTransition, watchDebounced } from "@vueuse/core";
|
||||||
|
import { computed, shallowRef, useId } from "vue";
|
||||||
|
import ScreenTransition from "./ScreenTransition.vue";
|
||||||
|
|
||||||
|
const {
|
||||||
|
visible,
|
||||||
|
message = "Loading…",
|
||||||
|
progress = undefined,
|
||||||
|
} = defineProps<{
|
||||||
|
visible: boolean,
|
||||||
|
message?: string,
|
||||||
|
// loading progress, range 0..1
|
||||||
|
progress?: number | undefined,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// CSS transition on width does not work in Firefox
|
||||||
|
const zeroProgress = computed(() => progress ?? 0);
|
||||||
|
const easedProgress = useTransition(zeroProgress, {
|
||||||
|
duration: 400,
|
||||||
|
easing: TransitionPresets.easeInOutQuad,
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressId = useId();
|
||||||
|
// Let the progress animation finish before cutting it off of updates
|
||||||
|
const actuallyVisible = shallowRef(visible);
|
||||||
|
watchDebounced(() => visible, () => {
|
||||||
|
actuallyVisible.value = visible;
|
||||||
|
}, { debounce: 600 })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ScreenTransition :visible="actuallyVisible">
|
||||||
|
<div class="tw:h-full tw:flex tw:flex-col tw:gap-8 tw:items-center tw:justify-center tw:text-2xl">
|
||||||
|
<label :for="progressId">
|
||||||
|
{{ message }}
|
||||||
|
</label>
|
||||||
|
<progress :id="progressId" class="progress" max="1" :value="easedProgress">
|
||||||
|
<template v-if="progress !== undefined">
|
||||||
|
{{ Math.floor(progress * 100) }} %
|
||||||
|
</template>
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
</ScreenTransition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
label {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
appearance: none;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
height: 24px;
|
||||||
|
padding: 5px;
|
||||||
|
width: 350px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 1px 5px #000 inset, 0 1px 0 #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::-webkit-progress-bar {
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* for some reason combining these two selector via comma breaks Chromium */
|
||||||
|
.progress::-webkit-progress-value {
|
||||||
|
appearance: none;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #444;
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, .5) inset;
|
||||||
|
|
||||||
|
background-image: -webkit-linear-gradient(135deg,
|
||||||
|
transparent 33%, rgba(0, 0, 0, .1) 33%,
|
||||||
|
rgba(0, 0, 0, .1) 66%, transparent 66%);
|
||||||
|
background-size: 35px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::-moz-progress-bar {
|
||||||
|
appearance: none;
|
||||||
|
background-color: #444;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, .5) inset;
|
||||||
|
|
||||||
|
background-image: repeating-linear-gradient(135deg,
|
||||||
|
transparent 0%, transparent 33%,
|
||||||
|
rgba(0, 0, 0, .1) 33%, rgba(0, 0, 0, .1) 66%);
|
||||||
|
background-size: 35px 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Transition>
|
||||||
|
<div v-if="visible" class="tw:h-full tw:overflow-hidden tw:isolate" style="background-color: var(--main-background-color);">
|
||||||
|
<div class="tw:h-full">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
@property --screen-transition-duration {
|
||||||
|
syntax: "<time>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* this placeholder is needed, because Vue.js can not figure out transition duration based on nested styles */
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity var(--screen-transition-duration) linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-active>div,
|
||||||
|
.v-leave-active>div {
|
||||||
|
transition: transform var(--screen-transition-duration) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.v-leave-to>div {
|
||||||
|
transform: scale(130%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Search from '@material-design-icons/svg/outlined/search.svg';
|
||||||
|
import Clear from '@material-design-icons/svg/outlined/clear.svg';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const {
|
||||||
|
placeholder = "Search…",
|
||||||
|
} = defineProps<{
|
||||||
|
placeholder?: string,
|
||||||
|
}>();
|
||||||
|
const model = defineModel({ type: String, required: true });
|
||||||
|
|
||||||
|
const clearDisabled = computed(() => model.value === "");
|
||||||
|
function clear() {
|
||||||
|
model.value = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tw:flex tw:gap-1 tw:p-0.5 tw:px-1 tw:place-items-center tw:justify-center tw:text-xl input-text">
|
||||||
|
<Search class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" style="color: #929292;" />
|
||||||
|
<input type="text" role="searchbox" v-model="model" :placeholder class="tw:flex-1 tw:min-w-0" />
|
||||||
|
<button type="button" role="button" name="clear" aria-roledescription="Clear search field" tabindex="-1" title="Clear"
|
||||||
|
@click="clear" :disabled="clearDisabled" class="button">
|
||||||
|
<Clear class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.button {
|
||||||
|
color: #929292;
|
||||||
|
transition: color 150ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active:not(:disabled) {
|
||||||
|
color: #767676;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
|
||||||
|
const trackStore = useTrackStore();
|
||||||
|
const timeline = useTimelineStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scene-wrapper">
|
||||||
|
<div class="scene">
|
||||||
|
<h3>
|
||||||
|
Preview Scene
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Track store status: {{ trackStore.status }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Current track: {{ trackStore.currentAudioTrackName }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Track status: {{ trackStore.audioTrackStatus }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Track progress: {{ trackStore.audioTrackProgress }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Playback status: {{ timeline.isPlaying }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scene-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
width: 480px;
|
||||||
|
height: 160px;
|
||||||
|
border: 5px solid #ccc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Timestamp from '@/components/timeline/Timestamp.vue';
|
||||||
|
import { LANGUAGES, type AudioTrack } from '@/lib/AudioTrack';
|
||||||
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const {
|
||||||
|
track,
|
||||||
|
edit,
|
||||||
|
} = defineProps<{
|
||||||
|
track: AudioTrack,
|
||||||
|
edit: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const beatSeconds = computed(() => track.loadedLoop!.duration / track.Beats);
|
||||||
|
const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="info-wrapper">
|
||||||
|
<table class="info-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Name:</td>
|
||||||
|
<td>
|
||||||
|
{{ track.Name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Language:</td>
|
||||||
|
<td>
|
||||||
|
<select v-if="edit" v-model="track.Language">
|
||||||
|
<option v-for="language in LANGUAGES" :value="language">{{ language }}</option>
|
||||||
|
</select>
|
||||||
|
<span v-else>
|
||||||
|
{{ track.Language }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Is explicit:</td>
|
||||||
|
<td><div style="display: flex; gap: 8px;">
|
||||||
|
<input type="checkbox" id="isExplicit" :checked="track.IsExplicit" :disabled="!edit" />
|
||||||
|
<label for="isExplicit" style="flex: 1;">
|
||||||
|
<div title="Warning: Explicit Content" class="tw:flex-none">
|
||||||
|
<Explicit class="tw:w-6 tw:h-6 tw:fill-current" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Intro:</td>
|
||||||
|
<td><Timestamp :seconds="track.WindUpTimer" :beats="windUpBeats" /><span style="vertical-align: bottom;"> = 4 * {{ (windUpBeats / 4).toFixed(2) }} bars</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Loop:</td>
|
||||||
|
<td><Timestamp :seconds="track.loadedLoop!.duration" :beats="track.Beats" /><span style="vertical-align: bottom;"> = 4 * {{ track.Beats / 4 }} bars</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.info-table td {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.info-table tr td:first-child {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: end;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table tr td:nth-child(2) {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ScrollablePanel from "@/components/library/panel/ScrollablePanel.vue";
|
||||||
|
import Construction from "@material-design-icons/svg/round/construction.svg";
|
||||||
|
import { computed, shallowRef } from "vue";
|
||||||
|
import AudioTrack from "./views/AudioTrack.vue";
|
||||||
|
import { useTimelineStore } from "@/store/TimelineStore";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
// TODO: use selection (inspector?) manager
|
||||||
|
const selection = shallowRef<object | null>({});
|
||||||
|
|
||||||
|
const timeline = useTimelineStore();
|
||||||
|
const { audioTrack, tracksMap } = storeToRefs(timeline);
|
||||||
|
|
||||||
|
const introClip = computed(() => tracksMap.value.intro.clips[0]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- inspector panel -->
|
||||||
|
<ScrollablePanel class="tw:flex-none tw:min-w-80 tw:max-w-80 tw:border-s">
|
||||||
|
|
||||||
|
<template #toolbar>
|
||||||
|
<h3 class="tw:flex tw:flex-row tw:items-center tw:gap-2 tw:px-4 tw:py-1 tw:select-none">
|
||||||
|
<Construction class="tw:fill-current tw:h-5 tw:w-5 toolbar-icon-shadow" />
|
||||||
|
Inspector
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<!-- inspector content -->
|
||||||
|
<div class="tw:px-4 tw:h-full tw:flex tw:flex-col" @click="selection = selection ? {} : {}">
|
||||||
|
|
||||||
|
<!-- nothing to inspect -->
|
||||||
|
<div v-if="!selection"
|
||||||
|
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none">
|
||||||
|
Nothing to inspect
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- inspect selection -->
|
||||||
|
<div v-else class="tw:flex-1 tw:flex tw:flex-col tw:gap-4 tw:py-2 tw:text-xs">
|
||||||
|
<AudioTrack v-if="audioTrack" :audioTrack />
|
||||||
|
<!-- <Clip v-if="introClip" :clip="introClip" /> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</ScrollablePanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Control } from "@/components/inspector/controls";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { getComponentFor } from "./impl";
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
} = defineProps<{
|
||||||
|
control: Control;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const view = computed(() => getComponentFor(control));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="view" :control="control" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Controls } from "../controls";
|
||||||
|
import Control from "./Control.vue";
|
||||||
|
|
||||||
|
const {
|
||||||
|
controls,
|
||||||
|
} = defineProps<{
|
||||||
|
controls: Controls;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
|
||||||
|
style="grid-template-columns: 80px minmax(0, 1fr);">
|
||||||
|
<Control v-for="control in controls" :key="control.key" :control />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BaseNamedControl } from "@/components/inspector/controls";
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
id,
|
||||||
|
} = defineProps<{
|
||||||
|
control: BaseNamedControl;
|
||||||
|
/**
|
||||||
|
* Input ID for an associated label.
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// TODO: reset function
|
||||||
|
function reset(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- label -->
|
||||||
|
<label :for="id" class="tw:text-right control-label" :class="{ 'control-label__disabled': control.disabled }"
|
||||||
|
@dblclick="reset">
|
||||||
|
{{ control.name }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- control -->
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ButtonControl } from "@/components/inspector/controls";
|
||||||
|
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
} = defineProps<{
|
||||||
|
control: ButtonControl;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseNamedControlView :control>
|
||||||
|
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-start">
|
||||||
|
<button type="button" @click="control.action" :disabled="control.disabled" class="control-button">
|
||||||
|
<component v-if="control.icon" :is="control.icon" class="control-button__icon" />
|
||||||
|
<span class="control-button__text">{{ control.text }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BaseNamedControlView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||