Compare commits

..

155 Commits

Author SHA1 Message Date
ivan tkachenko 81870ecd47 WIP: Add frontend web app player & editor in Vue 3 + Vite
TODO:
- implement viewing & editing.
- Add links to deployment, and CHANGELOG.
- Change web app icon.
2025-12-07 16:22:57 +02:00
ivan tkachenko 8a24448cb6 Consistent ordering of Easing functions in C# 2025-12-06 23:26:08 +02:00
ivan tkachenko a74bbfaee2 Add JSON exporter to debug builds 2025-11-23 15:56:58 +02:00
ivan tkachenko ad0a20cc7e Fix consistency of capitalization in csproj 2025-11-11 15:45:42 +02:00
ivan tkachenko 51e578f2da Migrate .sln to modern and simple .slnx 2025-11-10 18:10:21 +02:00
ivan tkachenko 3563fa2b36 Bump version 2025-11-10 18:08:16 +02:00
ivan tkachenko f790decc4d Release v1337.9001.1 2025-11-01 23:37:35 +02:00
ivan tkachenko 5a8f0201a3 Tweak color visual effects and lyrics for MoyaZhittya and other tracks 2025-11-01 23:37:34 +02:00
ivan tkachenko 825355dd54 Raise the default audio volume, add a configuration slider 2025-11-01 23:37:34 +02:00
ivan tkachenko c62535841a Shuffle README content, add link to mod's Discord thread 2025-11-01 18:20:41 +02:00
ivan tkachenko b0d96ff67e Bump version 2025-11-01 18:20:41 +02:00
ivan tkachenko 3607ccc92f Release v1337.9001.0 2025-10-15 19:10:29 +03:00
ivan tkachenko 8570505758 Rewrite README, add self-hosted videos 2025-10-15 19:10:06 +03:00
ivan tkachenko 049a14e440 Use requiresRestart: false directly, remove Default() options factory
Now, without an additional CanModifyCallback setup, Default is just a
pointless overhead.
2025-10-15 15:55:29 +03:00
ivan tkachenko 1ec8275831 Drop restrictions on when/whether host/clients can modify config
Lethal Config does not refresh that state reliably, it has only caused
bugs and annoyances so far. If someone wants to change the track mid-day,
there is a small chance to desync though.
2025-10-15 15:49:53 +03:00
ivan tkachenko 9efe6adaf3 Add a new track ReelGoon 2025-10-14 19:00:11 +03:00
ivan tkachenko a5b117e26d Add a new track Whistle 2025-10-14 17:48:37 +03:00
ivan tkachenko 01332ab77f Add a new track BbIXODaHET 2025-10-14 17:48:37 +03:00
ivan tkachenko 7aa3570b33 Fix internal representation of track AttentionPls
Members of track group were never intended to be Selectable.
2025-10-14 17:48:36 +03:00
ivan tkachenko e7866fda55 Fix division erroneously rounding to integers
This fixes LoopOffset which is a non-integer factor or Beats.
2025-10-14 17:48:36 +03:00
ivan tkachenko cd9e0a7a10 Compensate for loudness level -14 LUFS of audio files 2025-10-14 17:48:36 +03:00
ivan tkachenko 8223425b19 Remaster all tracks to target consistent loudness level of -14 LUFS
Add multiple intros for BeefLiver.
2025-10-14 16:53:11 +03:00
ivan tkachenko 9bf3a80341 Bump netcode-patch to support v73 2025-10-11 21:59:48 +03:00
ivan tkachenko 72a8016ab5 Clean up whitespace 2025-10-08 02:30:46 +03:00
ivan tkachenko 9619a75427 Bump version 2025-10-08 02:30:32 +03:00
ivan tkachenko ceaac4e01b Release v1337.420.9004 2025-09-27 03:13:49 +03:00
ivan tkachenko aea755361b Added new track AttentionPls, implement HUD effects as a time series / timeline 2025-09-26 18:07:11 +03:00
ivan tkachenko e67c72951e Override DeathScreen / GameOver text, add support for per-track strings
Custom message is shown only if the player dies to a Jester.
2025-09-22 03:01:41 +03:00
ivan tkachenko 0fadf50bf4 Bump version 2025-09-22 03:00:16 +03:00
ivan tkachenko 585ef604ff Release v1337.420.9003 2025-08-25 12:15:20 +03:00
ivan tkachenko 99babe8bdf Substitute placeholder nulls with per-light initial color for transitions
Fade out and first color transitions used to assume white as a default
color, which is not always the case e.g. in Mineshaft tunnel tiles. Use
nullable from/to/color fields, and substitute them with per-light
initial color data in a new virtual method that calculates colors.
2025-08-25 01:29:39 +03:00
ivan tkachenko bbd9b0204f Rename PoweredLightsAnimators.cs to PoweredLights.cs
For simplicity, but also because it already handles more than animators.
2025-08-24 22:34:33 +03:00
ivan tkachenko 70eabe75dd Bump version 2025-08-24 22:29:47 +03:00
ivan tkachenko 63de62111f Release v1337.420.9002 2025-08-23 02:34:17 +03:00
ivan tkachenko 4cc9713fa7 Fix resetting to wrong initial colors, e.g. in Mineshaft tunnel tiles
This does not fix fading out and transitioning to the very first palette
color though, but fixing that would require color events to
be "personalized" per-light, which is currently not supported.
2025-08-23 01:49:12 +03:00
ivan tkachenko 8710df7525 Change config value for Override Spawn Rates to true by default 2025-08-22 16:09:01 +03:00
ivan tkachenko 9d23fd5b95 Downgrade LobbyCompatibility to optional dependency
Since it does not prevent unmodded clients from joining, there is no
reason for literally any mod to require it.
2025-08-22 16:05:42 +03:00
ivan tkachenko 4516b853cd Remove remaining CSync code and references
There were issues with clients not being able to join, potentially
caused by linked (even though actually unused) CSync library.
2025-08-22 15:16:24 +03:00
ivan tkachenko b3767cbbf0 Add "polyfill" for IsExternalInit C# feature
Imperium does this as well, and the whole internet would tell you to do
this too, so it should be fine.
2025-08-22 15:16:24 +03:00
ivan tkachenko 327e606deb Drop required properties syntax
Sometimes, seemingly after random unrelated changes, it might stop
compiling with internal compiler error messages about missing features
and attributes. .NET Standard 2.1 is not supposed to support any
features beyond C# 8.0, while `required` attribute was introduced only
in C# 11 or 12, it's hard to tell.
2025-08-22 15:16:24 +03:00
ivan tkachenko 70e45d5ba2 Remove unused class 2025-08-22 15:16:24 +03:00
ivan tkachenko d4d3e15de3 Clean separation between track data and config overrides
In debug builds Config keeps a reference to the last set original track
instance from which it can load original values.
2025-08-22 15:16:23 +03:00
ivan tkachenko 525c0e108f Refactor CurrentTrack to be less dependent on a global static 2025-08-22 15:16:23 +03:00
ivan tkachenko 73ad702684 Rewrite AudioSource handling from scratch 2025-08-22 15:16:23 +03:00
ivan tkachenko e67de4556c Move BeatTimeState from global static to per-Jester-instance Behaviour 2025-08-22 15:16:11 +03:00
ivan tkachenko 0b0383003f Reset BeatTimeState for good measure
Hopefully will fix Mineshaft lights somehow getting stuck in multiplayer.
2025-08-22 15:16:11 +03:00
ivan tkachenko 9ed98197f8 Remaster track Beha and BeefLiver at conventional 44100 Hz 2025-08-22 15:16:09 +03:00
ivan tkachenko fe5752cbff Remaster track Beha and BeefLiver at conventional 44100 Hz 2025-08-21 15:30:47 +03:00
ivan tkachenko c6b128270f Add new track OnePartiyaUdar 2025-08-15 00:52:38 +03:00
ivan tkachenko 852d866073 Bump version 2025-08-15 00:51:53 +03:00
ivan tkachenko 6a9ea8d4af Release v1337.420.9001 2025-08-14 19:17:35 +03:00
ivan tkachenko 42c6179ba5 Add new track Beha with three variants of intro 2025-08-14 19:13:20 +03:00
ivan tkachenko 5649a18633 Split Track into Selectable and Audio interfaces, add support for groups 2025-08-14 18:48:54 +03:00
ivan tkachenko 47f984cd28 Allow tracks to share common audio clip files
Send one request per file name. File names can be explicitly overridden.
2025-08-14 15:38:59 +03:00
ivan tkachenko fc3a62e511 Rename Start segment to Intro to reduce some confusion
Confusingly, "start" may refer to too many things in different places,
while "intro" would unambiguously refer to an audio clip that plays
first before the loop starts.
2025-08-14 15:11:46 +03:00
ivan tkachenko 5f0c890682 Remove unused method 2025-08-14 15:09:31 +03:00
ivan tkachenko 59a069f51b Bump version 2025-08-14 15:09:27 +03:00
ivan tkachenko df796965f2 Release v1337.420.69 2025-08-11 22:28:57 +03:00
ivan tkachenko 26f9d2cf9f Print tracks length in debug builds, and remove unnecessary non-null assertion 2025-08-11 22:28:32 +03:00
ivan tkachenko a950093f8e Sort tracks by name, so they are easier to find in the config 2025-08-11 22:28:32 +03:00
ivan tkachenko 8842005898 Add new track BeefLiver 2025-08-11 22:28:31 +03:00
ivan tkachenko b4ae4bad41 Config: More usable range for fading out 2025-08-11 22:28:31 +03:00
ivan tkachenko 69e64397a0 Extrapolate AudioSource playback time to get smoother transitions
AudioSource only updates about 25 times per second, meaning that even at
30 fps some adjacent frames would be calculated as having exact same
timestamps and render duplicated colors. At 100+ fps more than 2/3 of
the frames would be duplicates.

As a drive-by change, split complex logic of BeatTimeState into smaller
classes. Most of the time the state needs to maintain some boolean flag
which it flips once and stays that way, like HasStarted, IsLooping.
2025-08-11 22:28:31 +03:00
ivan tkachenko 3d0795f04d Drop CSync as a dependency from Release builds
Since the rewrite of track selection to a custom netcode, CSync is only
needed for debug/development builds now.
2025-08-11 22:28:31 +03:00
ivan tkachenko 4abd0fb612 Fix stale event handlers causing errors in console 2025-08-11 22:28:30 +03:00
ivan tkachenko dd3c9647e3 Bump version 2025-08-11 22:28:29 +03:00
ivan tkachenko 8b2f4428bb Release v1337.69.420 2025-08-07 20:27:58 +03:00
ivan tkachenko 0dca416958 Rewrite track choosing event to custom netcode 2025-08-07 20:27:57 +03:00
ivan tkachenko 1aa8c1ddfa Fix Disco Ball hanging around after being disabled 2025-08-07 20:27:57 +03:00
ivan tkachenko 75d0ee2c1d Bump version 2025-08-07 20:27:57 +03:00
ivan tkachenko 2e938dfc8d Release v13.37.9001 2025-08-05 05:10:48 +03:00
ivan tkachenko 1ffdd5d97e Add spawn rate patch to make the event more likely 2025-08-05 05:10:21 +03:00
ivan tkachenko 276fbbec22 Clean up mention of removed config option "Enable Color Animations"
Amends 2a28a36a69
2025-08-05 05:10:11 +03:00
ivan tkachenko 05749ff122 Add Animator and Audio to MineshaftStartTile 2025-08-03 00:31:07 +03:00
ivan tkachenko f131ad7148 Fix NarrowHallwayTile2x2 mineshaft lights flickering 2025-08-03 00:31:07 +03:00
ivan tkachenko f50989b5ae Refactor: Optimize DiscoBallManager to create and cache at start of round 2025-08-03 00:31:06 +03:00
ivan tkachenko 72adb9e713 Refactor: Fix up visibility and static modifiers, and other minor things 2025-08-02 16:25:45 +03:00
ivan tkachenko 76e9ca3595 Refactor: Make State an internal class of JesterPatch class 2025-08-02 16:12:44 +03:00
ivan tkachenko b6f2ca355b Refactor: Factor out displaying lyrics as a tip in its own method 2025-08-02 15:54:07 +03:00
ivan tkachenko 78370da460 Fix LEDHangingLight (GarageTile & PoolTile) lights flickering 2025-08-02 15:50:59 +03:00
ivan tkachenko 4d84a2d001 Fix multiple Light components per animator
Add them all to the allPoweredLights list,
not just the whatever first one was found.
2025-08-02 15:50:59 +03:00
ivan tkachenko 0eb02698eb Fix KitchenTile lights flickering 2025-08-02 01:04:12 +03:00
ivan tkachenko c7b67b9042 Update manifest, README and project files 2025-08-02 01:04:11 +03:00
ivan tkachenko f53f837e3f Bundle CHANGELOG.md 2025-08-01 23:10:36 +03:00
ivan tkachenko 86644388f3 Bump version 2025-08-01 23:10:35 +03:00
ivan tkachenko c0e7185321 Release v13.37.1337 2025-08-01 16:49:42 +03:00
ivan tkachenko 9062f386de Fix/add light flickering with animator controllers 2025-08-01 16:48:16 +03:00
ivan tkachenko 3a2eaad493 Add more light flickering to the track Kach 2025-08-01 02:55:27 +03:00
ivan tkachenko b70e868ac4 Rename DiscoBall asset bundle
There is going to be another bundle, so we want some distinctive names.
2025-07-31 21:44:52 +03:00
ivan tkachenko bacb9f07c7 Use StartOfRound.Instance.audioListener for lyrics events
Probably doesn't make a difference, but it's nice to be able to
calculate audio source<->listener distance directly.
2025-07-30 20:09:17 +03:00
ivan tkachenko 2a28a36a69 Config: Remove EnableColorAnimations toggle
Turns out, it doesn't really affect anything. AMD on Linux would lag anyway.
2025-07-30 18:56:34 +03:00
ivan tkachenko 841ccc74ed Fix color transition from a negative beat 2025-07-30 18:56:33 +03:00
ivan tkachenko 8729515537 Fix timings of fade out and lyrics for DeployDestroy 2025-07-30 18:56:33 +03:00
ivan tkachenko 991e2a56b7 Fix color right before wrapping
The buggy Split method was erroneously creating a looping span despite
explicitly passing `isLooping: false` parameter because with
`beatToInclusive: LoopBeats` wrapping will occur regardless. This
messed up with Duration calculations, and eventually caused the last
beat default to transition with t=0, when it should really be static.
2025-07-30 18:56:33 +03:00
ivan tkachenko c689198588 Fix fading out: set pure black at the end 2025-07-30 18:56:33 +03:00
ivan tkachenko 667368d719 Add specialized color transition event to improve debug output 2025-07-30 18:37:59 +03:00
ivan tkachenko 6a0be0d780 Enable nullable reference types 2025-07-30 18:37:58 +03:00
ivan tkachenko 0573091162 Auto formatting 2025-07-30 18:37:58 +03:00
ivan tkachenko 2ef0fc3bd9 Fix up all logs to use nameof() instead of hardcoded string 2025-07-30 18:37:58 +03:00
ivan tkachenko ce437aa86c Events: Mark BaseEvent as abstract
It's not useful on its own
2025-07-30 18:37:57 +03:00
ivan tkachenko 7ed299ead8 Fix AudioSource distance check for lyrics event
It was checking maxDistance of a non-overridden loop clip during windup.
2025-07-30 18:37:56 +03:00
ivan tkachenko f959a4ebb2 Setup LobbyCompatibility as a dependency
This should help to avoid desync issues.
2025-07-30 01:29:07 +03:00
ivan tkachenko 7a5013524d Prevent Publicizer Warnings from Showing 2025-07-30 00:08:08 +03:00
ivan tkachenko 14a57fcae7 Mark referenced packages with Private attributes
Apparently, this is considered a good practice. Although Private="false"
is supposed to not copy the dependency into the output directory, which
didn't happen anyway?
2025-07-30 00:08:08 +03:00
ivan tkachenko 47876b18bf Fix up csproj XML formatting 2025-07-29 23:45:20 +03:00
ivan tkachenko 5abad0b1ba Bump version 2025-07-29 23:45:19 +03:00
ivan tkachenko 1cdbdf2f09 Release v13.37.911 2025-07-21 19:16:25 +03:00
ivan tkachenko 45a73793fb Add support for pre-v70 Mansion Main tile. 2025-07-21 19:07:36 +03:00
ivan tkachenko 581d9701bd Remove redundant call to private method FlickerPoweredLights
FlickerPoweredLights is a private coroutine.

FlickerLights is the public method that internally starts and stores the
FlickerPoweredLights coroutine.
2025-07-21 19:07:36 +03:00
ivan tkachenko 49ac86e6f9 Add compatibility section to the README
readme
2025-07-21 19:07:35 +03:00
ivan tkachenko 0f8ab1a75b Add Changelog 2025-07-21 19:07:35 +03:00
ivan tkachenko dda00ce228 Bump version 2025-07-21 19:07:35 +03:00
ivan tkachenko 39a8255532 Fix LethalConfig dependency string
Apparently, this is different from BepInEx plugin GUID.
2025-07-21 02:06:14 +03:00
ivan tkachenko b7eb4ce60b Update README 2025-07-21 01:56:49 +03:00
ivan tkachenko d6a2bf21b1 Bump version 2025-07-21 01:13:40 +03:00
ivan tkachenko 730f125d62 Patch Jester destructor to reset the light show
It is needed to despawn Jester via Imperium's Object Explorer.
2025-07-21 01:06:45 +03:00
ivan tkachenko 8e065d3e51 Add config option to skip tracks marked as Explicit Content/Lyrics
Unfortunately it is configurable by host only, and there is no sane way
to make work from clients.
2025-07-21 00:55:40 +03:00
ivan tkachenko 2a33457661 Harmony: Use nameof() instead of hardcoded strings 2025-07-21 00:32:43 +03:00
ivan tkachenko 0fbf0b04f4 Add V70PoweredLights_Fix to the package dependencies
Not strictly required, but makes this mod shine brighter.
2025-07-21 00:32:43 +03:00
ivan tkachenko 0c5d4f7158 Add DiscoBall to Main on all interiors, BirthdayRoom and factory (belt room) 2025-07-21 00:32:43 +03:00
ivan tkachenko 9e066372c5 Add support for lyrics randomization 2025-07-21 00:32:42 +03:00
ivan tkachenko ca977625db Sort imports the way VisualStudio likes it
Apparently, Sublime Text's Sort Lines command ordered them in a weird
way, inconsistent with VS, VS Code and human logic.
2025-07-20 23:04:41 +03:00
ivan tkachenko 7d1cac6e2e Add lyrics, flickering and fade out transitions to many tracks 2025-07-20 23:04:40 +03:00
ivan tkachenko 2229fa3545 Add debug-only config for lyrics time series 2025-07-20 23:04:40 +03:00
ivan tkachenko 118eecbb59 Add support for fading out, and debug-only config for flickering lights 2025-07-20 23:04:40 +03:00
ivan tkachenko b8824dbbfb Config: Most synced options have something in common 2025-07-20 23:04:40 +03:00
ivan tkachenko 3e751c0d8d Config: reduce repetition
It may look complicated, but it reduced references to each individual
entry from almost ten to just 4.
2025-07-20 23:04:39 +03:00
ivan tkachenko 601ecf8887 Reworked state management system, automatic wrapping of timestamps and spans
Add lyrics for MoyaZhittya
2025-07-20 23:04:39 +03:00
ivan tkachenko d13c617895 Apply audio offsets early to simplify math 2025-07-18 02:40:55 +03:00
ivan tkachenko e1f19b3919 Add track Kach with custom palette and timings 2025-07-18 02:40:55 +03:00
ivan tkachenko ba0162b3e1 Add track PWNED with custom palette and timings 2025-07-18 02:40:54 +03:00
ivan tkachenko ed8804b7a7 Add track Chereshnya with custom palette and timings 2025-07-18 02:40:53 +03:00
ivan tkachenko 9be9eaaf80 Extend loop of the track VseVZale
Now includes second phase OOOoooo OOooo
2025-07-18 02:40:53 +03:00
ivan tkachenko 0683a18491 Port track VseVZale to OGG format
No gap now.
2025-07-18 02:40:52 +03:00
ivan tkachenko 6204888453 Port track DeployDestroy to OGG format
No gap now.
2025-07-18 02:40:52 +03:00
ivan tkachenko c15637b347 Port track Durochka to OGG format, add custom transitions
No gap now.
2025-07-18 02:40:51 +03:00
ivan tkachenko 42c1f29a16 Port track Gorgorod to OGG format
No gap now.
2025-07-18 02:40:50 +03:00
ivan tkachenko 8a193fa408 Port track MoyaZhittya to OGG format
No gap now.
2025-07-18 02:40:50 +03:00
ivan tkachenko 4ee20adea7 Port track MuzikaGromche to OGG format
Twice as longer, loops better, no gap.
2025-07-18 02:40:49 +03:00
ivan tkachenko 2df7d28d43 New operators for Palette
With these it would be easier to create more complicated timelines
without repeating yourself over and over again.
2025-07-18 02:40:49 +03:00
ivan tkachenko 43d1565dbe MSBuild: Add platform-agnostic task to convert WAV to OGG 2025-07-18 02:40:48 +03:00
ivan tkachenko f5dab20d67 Add track Yalgaar with custom palette and timings 2025-07-18 02:40:48 +03:00
ivan tkachenko 38cfb5f5e7 Add track Peretasovka with custom palette and timings 2025-07-18 02:40:47 +03:00
ivan tkachenko b86c50a848 Add track Song2 with custom palette and timings 2025-07-18 02:40:47 +03:00
ivan tkachenko 694bc61dae Add tracks GodMode and RiseAndShine with custom palette and timings 2025-07-18 02:40:46 +03:00
ivan tkachenko 909efa720f Add track ZmeiGorynich with custom palette and timings 2025-07-18 02:36:43 +03:00
ivan tkachenko a8761bf679 Add support for interpolated color transitions for lights, with debug-only synced overrides 2025-07-17 22:36:52 +03:00
ivan tkachenko ad77530b6d Add support for per-track palettes, and debug-only synced palette override
Palettes are contributed by @REALJUSTNOTHING
2025-07-17 22:35:19 +03:00
ivan tkachenko 34d8da1562 Add configurable global audio offset, useful for Bluetooth headsets 2025-07-17 22:35:19 +03:00
ivan tkachenko b73c7ee3cb Sync playback to the actual beat count rather than relying on BPM 2025-07-17 22:34:38 +03:00
ivan tkachenko 0d4f180a37 Add debug-only ability to change weights of tracks while on a moon
And drop obsoleted debug code. With the new on-the-fly track weights
configuration, hardcoding one in build is not necessary anymore.
2025-07-16 03:06:43 +03:00
ivan tkachenko 829c44e347 Add debug-only synced config option to skip wind-up phase 2025-07-16 03:06:42 +03:00
ivan tkachenko b15e93ac34 Factor out CSync hack into a separate method
We gonna register more synced entries in debug-only builds, but marking
nullable fields with the [SyncedEntryField] attribute is not an option.
2025-07-15 22:47:02 +03:00
ivan tkachenko f158e7728c Fix language section toggle for non-host
There is no point in checking for synchronized value before flipping
local value.
2025-07-15 21:51:38 +03:00
ivan tkachenko 2b42899779 Reorder some statements to make them visually more grouped together
Postfix patch went from 5 if-blocks down to only 3 \o/

There is no need to stop the creatureVoice and start it delayed in two
separate condition blocks. Also, the code should only rely on state
transitions, and not on AudioSource.isPlaying property.
2025-07-14 14:44:17 +03:00
221 changed files with 19349 additions and 353 deletions

5
.editorconfig Normal file
View File

@ -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

69
AGENTS.md Normal file
View File

@ -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/AttentionPls1Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/AttentionPls2Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/AttentionPlsLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BbIXODaHETIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BbIXODaHETLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BeefLiver1Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BeefLiver3Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BeefLiver4Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BeefLiver4Loop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BeefLiverLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/Beha1Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/Beha2Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/Beha3Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/BehaLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/ChereshnyaIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/ChereshnyaLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/DeployDestroyIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/DeployDestroyLoop.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/DeployDestroyLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/DeployDestroyStart.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/DurochkaIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/DurochkaLoop.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/DurochkaLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/DurochkaStart.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/GodModeIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/GodModeLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/GorgorodIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/GorgorodLoop.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/GorgorodLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/GorgorodStart.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/KachIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/KachLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/MoyaZhittyaIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/MoyaZhittyaLoop.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/MoyaZhittyaLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/MoyaZhittyaStart.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/MuzikaGromcheIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/MuzikaGromcheLoop.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/MuzikaGromcheLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/MuzikaGromcheStart.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/OnePartiyaUdarIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/OnePartiyaUdarLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/PWNEDIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/PWNEDLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/PeretasovkaIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/PeretasovkaLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/ReelGoonIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/ReelGoonLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/RiseAndShineIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/RiseAndShineLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/Song2Intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/Song2Loop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/VseVZaleIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/VseVZaleLoop.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/VseVZaleLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/VseVZaleStart.mp3 (Stored with Git LFS)

Binary file not shown.

BIN
Assets/WhistleIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/WhistleLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/YalgaarIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/YalgaarLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/ZmeiGorynichIntro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Assets/ZmeiGorynichLoop.ogg (Stored with Git LFS) Normal file

Binary file not shown.

94
CHANGELOG.md Normal file
View File

@ -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.

5
Directory.Build.targets Normal file
View File

@ -0,0 +1,5 @@
<Project>
<Target Name="NetcodePatch" AfterTargets="PostBuildEvent">
<Exec Command="dotnet netcode-patch -uv 2022.3.62 -nv 1.12.0 &quot;$(TargetPath)&quot; @(ReferencePathWithRefAssemblies->'&quot;%(Identity)&quot;', ' ')"/>
</Target>
</Project>

49
Frontend/.gitignore vendored Normal file
View File

@ -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

3
Frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

44
Frontend/README.md Normal file
View File

@ -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/`.

15
Frontend/index.html Normal file
View File

@ -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>

52
Frontend/package.json Normal file
View File

@ -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"
]
}
}

3856
Frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
Copy /Assets/ to this directory.

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
Frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
Frontend/public/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -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);
});

14
Frontend/src/App.vue Normal file
View File

@ -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>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

View File

@ -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;

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

Some files were not shown because too many files have changed in this diff Show More