├── .babelrc ├── .git-blame-ignore-revs ├── .gitconfig ├── .github └── workflows │ └── webpack.yml ├── .gitignore ├── .jshintrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── docs ├── Editor.md ├── MP3 Export.md ├── Respacks.md └── img │ ├── hues_420.png │ ├── hues_cowbell.png │ ├── hues_default.png │ ├── hues_hlwn.png │ ├── hues_montegral.png │ ├── hues_snoop.png │ └── hues_xmas.png ├── favicon.ico ├── fonts ├── HuesExtra.eot ├── HuesExtra.svg ├── HuesExtra.ttf ├── HuesExtra.woff └── PetMe64.woff ├── img ├── bones.png ├── left-hand.png ├── lightbase.png ├── lightoff.png ├── lightoff_inverted.png ├── lighton.png ├── lighton_inverted.png ├── right-hand.png ├── skull-eyes.png ├── skull.png ├── tombstone.png ├── tombstone_invert.png ├── vignette.png ├── web-bottomright.png ├── web-topleft.png ├── web-topright.png ├── wiresbottom.png ├── wiresleft.png └── wiresright.png ├── index.html ├── package-lock.json ├── package.json ├── respack_edit.html ├── src ├── css │ ├── hues-main.css │ ├── hues-respacks.css │ ├── hues-settings.css │ ├── huesUI-hlwn.css │ ├── huesUI-modern.css │ ├── huesUI-retro.css │ ├── huesUI-weed.css │ └── huesUI-xmas.css └── js │ ├── Components │ └── HuesButton.svelte │ ├── EventListener.ts │ ├── HuesCanvas2D.ts │ ├── HuesCore.ts │ ├── HuesEditor.ts │ ├── HuesEditor │ ├── EditorBox.svelte │ ├── InputBox.svelte │ ├── Main.svelte │ ├── SongStats.svelte │ ├── Timelock.svelte │ └── Waveform.svelte │ ├── HuesIcon.ts │ ├── HuesInfo.svelte │ ├── HuesInfoList.svelte │ ├── HuesRender.ts │ ├── HuesSetting.svelte │ ├── HuesSettings.svelte │ ├── HuesSettings.ts │ ├── HuesUI.ts │ ├── HuesWindow.ts │ ├── ResourceManager.ts │ ├── ResourcePack.ts │ ├── RespackEditor │ ├── App.svelte │ ├── ImageEdit.svelte │ └── main.ts │ ├── SoundManager.ts │ ├── UniqueID.ts │ ├── Utils.ts │ ├── audio │ ├── audio-worker.js │ ├── aurora.js │ ├── mpg123.js │ ├── ogg.js │ ├── opus.js │ └── vorbis.js │ └── global.d.ts ├── svelte.config.js ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-classes"] 4 | } 5 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Global run of Prettier 2 | 6ff7c7afe7f087c2ed79335d9dba693f021abb21 3 | -------------------------------------------------------------------------------- /.gitconfig: -------------------------------------------------------------------------------- 1 | [blame] 2 | ignoreRevsFile = .git-blame-ignore-revs 3 | -------------------------------------------------------------------------------- /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: Webpack 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Build 26 | run: | 27 | npm install 28 | npx webpack 29 | 30 | - name: Save artifacts 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: hues 34 | path: dist/**/* 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | respacks/ 2 | node_modules/ 3 | dist/ 4 | 5 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 11, 3 | // Allow array["access"]. We use this with localStorage to avoid any 4 | // aggressive minification doing variable name optimisation 5 | "sub": true, 6 | "loopfunc": true 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "svelte.svelte-vscode", 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "gitlens.advanced.blame.customArguments": [ 5 | "--ignore-revs-file", 6 | ".git-blame-ignore-revs" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 William Toohey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 0x40-web 2 | 3 | A fairly complete HTML5/CSS3 Canvas + Web Audio clone of the 0x40 Hues Flash. 4 | 5 | Should work on most modern browsers. 6 | 7 | ## Example pages: 8 | 9 | [Default Hues 10 | ![](docs/img/hues_default.png)](https://0x40.mon.im/) 11 | [420 Hues 12 | ![](docs/img/hues_420.png)](https://420.mon.im/) 13 | [Halloween Hues 14 | ![](docs/img/hues_hlwn.png)](https://spook.mon.im/) 15 | [Christmas Hues 16 | ![](docs/img/hues_xmas.png)](https://xmas.moe/) 17 | 18 | You can also have animations that sync perfectly with the beats of the songs. Inspired by Kepstin's Integral experiments. 19 | [420 Hues, Snoop Edition 20 | ![](docs/img/hues_snoop.png)](https://420.mon.im/snoop.html) 21 | ["Montegral" 22 | ![](docs/img/hues_montegral.png)](https://0x40.mon.im/montegral.html) 23 | [More Cowbell 24 | ![](docs/img/hues_cowbell.png)](https://0x40.mon.im/cowbell.html) 25 | 26 | For some examples of **fast, complicated and fancy** maps, here are some of my personal creations: 27 | [Black Banshee - BIOS](https://0x40.mon.im/custom.html?packs=BIOS.zip) 28 | [Drop It](https://0x40.mon.im/custom.html?packs=drop_it.zip) 29 | [Atols - Eden (buildup only)](https://0x40.mon.im/custom.html?packs=eden.zip) 30 | [AAAA - Hop Step Adventure](https://0x40.mon.im/custom.html?packs=hopstep.zip) 31 | [MACROSS 82-99 - ミュン・ファン・ローン](https://0x40.mon.im/custom.html?packs=macross.zip) 32 | [MDK - Press Start (VIP Mix)](https://0x40.mon.im/custom.html?packs=press_start.zip) 33 | [Alex Centra - Roguebot [Inspected]](https://0x40.mon.im/custom.html?packs=roguebot.zip) 34 | [Elenne - Vertical Smoke](https://0x40.mon.im/custom.html?packs=smoke.zip) 35 | [Nicky Flower - Wii Shop Channel (Remix)](https://0x40.mon.im/custom.html?packs=wii_remix.zip) 36 | [Nhato - Logos](https://0x40.mon.im/custom.html?packs=logos.zip) 37 | [Massive New Krew - HADES](https://0x40.mon.im/custom.html?packs=HADES.zip) 38 | 39 | Finally there's these, which hook into the Hues javascript events to do something fresh: 40 | [Doors](https://0x40.mon.im/doors.html) 41 | [Does Lewis Have A Girlfriend Yet (xox love ya)](https://0x40.mon.im/lewis.html) 42 | 43 | ## Creating your own songs 44 | 45 | 0x40 Hues comes with an integrated editor to create new songs and inspect existing ones. 46 | [Read how to use it here](https://github.com/mon/0x40-web/blob/master/docs/Editor.md) - it's easier than you think! 47 | 48 | ## Editing respacks 49 | 50 | There is an extremely basic respack editor at respack_edit.html. I also host it 51 | on [my site](https://0x40.mon.im/respack_edit.html). It does not support adding 52 | images, nor does it support adding songs. You can, however, edit all properties 53 | of an existing respack's songs and images. If this is lacking features you would 54 | like, please open a ticket. It was mostly made for editing centerPixel values. 55 | 56 | ## Install (Make your own Hues website) 57 | 58 | 1. Start by downloading the latest [release](https://github.com/mon/0x40-web/releases) 59 | 2. Put your respack zips somewhere they can be found by your web server. My hues have a `respacks/` folder under the main directory 60 | 3. Edit `index.html`: 61 | 4. Edit the `defaults` object so the `respacks` list contains the respacks you wish to load 62 | 5. _Optional:_ Add any extra settings to the `defaults` object 63 | 6. Upload everything to your server! 64 | 65 | ### Example settings 66 | 67 | ```javascript 68 | var defaults = { 69 | respacks: ["./respacks/Defaults_v5.0_Opaque.zip", "./respacks/HuesMixA.zip"], 70 | firstSong: "Nhato - Miss You", 71 | }; 72 | ``` 73 | 74 | ## Settings object 75 | 76 | See [HuesSettings.ts](./src/js/HuesSettings.ts#L10) for the possible options you 77 | can put into the `defaults` object. 78 | 79 | ## Query string 80 | 81 | Any setting that can go in the `defaults` object can also be dynamically specified in the URL. 82 | For example: https://0x40.mon.im/custom.html?packs=BIOS.zip,kitchen.zip¤tUI=v4.20 83 | 84 | There are two special settings here: 85 | 86 | - `firstSong` can just be written as `song`. 87 | - Anything given as `packs` or `respacks` will be appended to the respacks 88 | specified in the `defaults` object, as opposed to overwriting them. 89 | 90 | ## Building 91 | 92 | Install [Node.js](https://nodejs.org/en/). I currently use v18, but it should 93 | work with newer releases. 94 | 95 | Install the required packages for the build: 96 | 97 | ```bash 98 | npm install 99 | ``` 100 | 101 | Build with `npx webpack`. It will create a `dist` folder. For seamless 102 | development with auto-reload, `npx webpack serve` - if you do this, put any 103 | respacks in `public/respacks` so they're found by the local server. 104 | 105 | ## Adding a new beat character 106 | 107 | There's a few places to change, here's a list: 108 | 109 | - The documentation in the INFO tab. Found in `HuesInfo.svelte` 110 | - The mouseover documentation & button for the beat in EDITOR. Found in `HuesEditor/Main.svelte` 111 | - The list of beats in `HuesCore.ts` 112 | - If you've added some new display behaviour: 113 | - A new beat type in the `Effect` enum 114 | - A handler in the `beater` function 115 | - Appropriate state for the effect in `HuesRender.ts` 116 | - Appropriate rendering code in `HuesCanvas.ts` 117 | -------------------------------------------------------------------------------- /docs/Editor.md: -------------------------------------------------------------------------------- 1 | # Beatmap Editor 2 | 3 | Creating new songs is the heart of the Hues experience. The inbuilt editor makes 4 | it a breeze! To get to it, either hit your `e` key or click the settings cog, 5 | then hit `EDITOR`. 6 | 7 | Before you begin, you'll actually need a song to edit! You might be able to find 8 | good loops online, or you can make your own from a song you enjoy. The best way 9 | to make your own is using Audacity, detailed in the [MP3 10 | guide](MP3%20Export.md). 11 | 12 | 1. Load your loop using the `LOAD LOOP` button. If everything went well, it 13 | should start playing. 14 | 2. In the `Title` box, enter the Artist - Song Name combination, e.g. "Madeon - 15 | Finale" (without quotes) 16 | 3. Enter a source into the `Source` box if you have it - if you share your loop, 17 | it's nice to give other people a link to a high quality original. 18 | 19 | Now your loop is playing! Adjust time with the `HALVE` and `DOUBLE` buttons 20 | until the loop's beats match up with your beatmap. If you're happy with it, you 21 | can click the lock icon next to the beat count, and entered beats will override 22 | previous beats instead of adding to the total. 23 | 24 | From here, you can edit the rhythm. Check the Beat Glossary on the `INFO` tab to 25 | see what characters you can use. 26 | 27 | A good way to start is lining up the bass or snare hits before moving on to 28 | another instrument, rather than trying to do everything at once. If you find 29 | yourself needing more space for notes, you can always use `DOUBLE`. 30 | 31 | Once you've made your loop, you can optionally add a Buildup and repeat the 32 | process to edit its map. 33 | 34 | When you're finished, **don't forget** to copy or save the XML to save your 35 | work! You can then put your song into a [respack](Respacks.md) and share it! 36 | 37 | ### Banks 38 | For more complicated mapping, you may want to start combining effects in new and 39 | exciting ways! For that, use Banks. Every map has at least one bank, and you can 40 | add as many as you want. The beatmap visualiser will look at each bank in 41 | sequence and apply all the effects it sees. 42 | 43 | The most useful way to use banks is to change the way time based effects work. 44 | For example, a colour fade will fade until the next beat character. If you want 45 | to have a fade running at the same time as blurs, you can use Bank 1 to perform 46 | the fade, and Bank 2 to perform the blurs - the time calculations only take into 47 | account characters in the bank they start in. 48 | 49 | ### Editing tips 50 | - **Right click on the beatmap to seek** to that position. Don't wait until the 51 | song repeats! 52 | - Use the buttons at the bottom left to slow the song down and make tricky 53 | sections easier to map. 54 | - Rewind to the start of the song or the start of the buildup with the arrows 55 | next to the `Buildup` and `Rhythm` labels. 56 | - If you need more room to edit a part, resize it with the handle in between the 57 | sections. 58 | - If your song isn't in 4/4 time, try changing the `New line at beat` setting so 59 | your bars line up. 60 | - Use the beat buttons at the bottom of the editor to input non-typeable 61 | characters like `→` or `¤`. 62 | 63 | One last advanced tip - if your buildup is crazy different from your rhythm and 64 | is proving hard to map, click the chain icon on the left to unlink the 2 65 | sections. **Your song will no longer be compatible with the flash** but the 66 | buildup and rhythm can have separate map lengths. Let your creativity go nuts! 67 | 68 | *This tutorial heavily based on [the original](http://0x40hues.blogspot.com/p/0x40-hues-creation-tutorial.html).* 69 | -------------------------------------------------------------------------------- /docs/MP3 Export.md: -------------------------------------------------------------------------------- 1 | # Exporting MP3s with LAME 2 | 3 | A well formatted MP3 is essential to creating a loop that doesn't "skip" when it repeats. The best way to do this is using Audacity and LAME. 4 | 5 | 1. Download [Audacity](http://www.audacityteam.org/download/). 6 | 2. Download [LAME for Audacity](http://lame.buanzo.org/). This website is pretty dense - you're looking for the .exe if you're on Windows, or .dmg for Mac. If you're on Linux, you probably don't need this guide. 7 | 3. Install both, then open Audacity. 8 | 4. Drag your audio file to the Audacity window. 9 | 5. Select the looping section with your mouse. Test that it loops well by shift-clicking on the "Play" button. If it doesn't, adjust the selection handles until it does. 10 | 6. Click File->Export Selected Audio 11 | 7. Make sure the type is "MP3 Files". 12 | 8. Hit "Options" and make the Bit Rate Mode "Average" and the Quality "192kbps". This is a good balance between file size and audio fidelity. 13 | 9. Hit save! You now have a fresh MP3 ready to use in your very own Hues :) -------------------------------------------------------------------------------- /docs/Respacks.md: -------------------------------------------------------------------------------- 1 | # Resource Packs 2 | Resource Packs (respacks) are what make Hues tick. They contain the songs and images that are played when it is loaded. 3 | 4 | It helps to examine a pre-existing respack to understand how they work. There are several available on the [0x40 Hues Blogspot](http://0x40hues.blogspot.com/p/blog-page_5.html). 5 | 6 | Respacks are a simple .zip file and contain .xml files for information, and image and music files to be loaded. Folders and locations do not matter, but it can help to organise your respacks so that images, animated images and songs are in separate folders, and information xml files are in the top level. 7 | 8 | ## info.xml 9 | An info.xml file provides information about who made the respack, a brief description, and a link. 10 | 11 | An example structure is as follows: 12 | ```xml 13 | 14 | My Awesome Respack 15 | Me! 16 | I made song songs, and put them in a respack 17 | http://www.example.com/ 18 | 19 | ``` 20 | 21 | The options should be fairly self explanatory. Respack names are printed to console on load, and other respack information is visible in the Respacks tab. 22 | 23 | ## Images and images.xml 24 | *An images.xml file is not mandatory*. Simply putting images into your respack is enough to get them loaded. However, if you want to do something fancy, such as aligning an image to a certain side of the screen, you will need to create an `images.xml` file. 25 | 26 | An example structure is as follows: 27 | ```xml 28 | 29 | 30 | right 31 | 32 | 33 | My Cool Animation 34 | 45 35 | 36 | 37 | 38 | ``` 39 | 40 | Each `image` element must have a `name`. This refers to the filename (minus extension) of the image we are talking about. 41 | 42 | Possible options for images are: 43 | 44 | Name | Options | Default | Description 45 | --- | --- | --- | --- 46 | fullname | Any text | The image filename | If you would like a longer name than your file, specify one here. Some UIs display the longer name, some display the shorter name. 47 | align | `left`, `right`, `center` | `center` | If the "Smart align images" option is set, the image will be aligned to the specified side of the screen. 48 | source | Any link | None | If you would like to provide a link to where you found the image, put one here. It will be clickable in the UI 49 | centerPixel (**web Hues only**) | Any number | None | If the screen used to view hues is smaller than the image (for example, a vertical phone), which pixel to use as the center point for cropping. The respack editor is useful to understand how this works. 50 | 51 | ### Animations 52 | Animations are a special class of image. Because of limitations with using either gifs or videos, animations must be individual frames saved in the respack. The name of animated files must be `Name_x.ext` where `x` is the frame number and `ext` is png/jpg etc. 53 | 54 | Additional options for animations are: 55 | 56 | Name | Options | Default | Description 57 | --- | --- | --- | --- 58 | frameDuration | Comma separated numbers, eg `33,45,20`| `33` | How long (in ms) each frame will display. Each frame can have a different length. If there are more listed durations than frames, they are ignored. If there are fewer listed durations than frames, the last duration is reused for any extra frames. For example, if every frame is 40ms long, just use `40`. 59 | beatsPerAnim (**web Hues only**) | Any number | None | For synchronising animations to songs. Sets how many beats a single loop of this animation runs for. If the currently playing song has a matching `charsPerBeat` setting, the animation will be synchronised. Otherwise, it will fall back to the `frameDuration` set. 60 | syncOffset (**web Hues only**) | Any number | `0` | If the "beat" of your synchronised animation does not occur on frame 1, use this value to shift it. 61 | 62 | 63 | ## Songs and songs.xml 64 | If your respack contains songs, *a songs.xml file is mandatory*. 65 | 66 | Here is an example song structure: 67 | ```xml 68 | 69 | 70 | Netsky - Puppy 71 | http://www.youtube.com/watch?v=FU4cnelEdi4 72 | o...x...o...x...o...x...o... 73 | puppy_build 74 | .-...:......:...-... 75 | 4 76 | 77 | 78 | Blake McGrath- Motion Picture (Pegboard Nerds Remix) 79 | o...x...o... 80 | motion picture_Build 81 | -...-...-...-...-... 82 | true 83 | 84 | 85 | ``` 86 | 87 | Like `image` elements, each `song` element must have a `name`. This refers to the filename of the loop, minus extension. 88 | 89 | The [editor](Editor.md) can export song XML data. It is recommended you use it to avoid making spelling or formatting mistakes when doing it manually. 90 | 91 | Possible options for songs are: 92 | 93 | Name | Options | Default | Description 94 | --- | --- | --- | --- 95 | title | Any text | `` | The full name of the song 96 | source | Any text | None | The source URL of the song, clickable in the UI 97 | rhythm (**required**) | Any text | None | The beatmap of the song. Create one in the [editor](Editor.md). 98 | rhythm2, rhythm3... (**web Hues only**) | Any text | None | Additional banks of beats to run in parallel. 99 | buildup | Filename minus extension | None | The filename of the buildup - the lead-in to the main loop. 100 | buildupRhythm | Any text | `.` for the entire build | A rhythm for the buildup, if any. 101 | buildupRhythm2, buildupRhythm3... (**web Hues only**) | Any text | None | Additional banks of buildup beats to run in parallel. 102 | independentBuild (**web Hues only**) | Anything | None | By default, the length of a buildup is set so the buildup beatmap runs at the same speed as the main loop. If this is set, the buildup's beatmap can be any length, and will run faster or slower than the main loop. Best set using the [editor](Editor.md). 103 | charsPerBeat (**web Hues only**) | Any number | None | For synchronising animations. Specifies how many characters of the beatmap make up a beat in the song. If an animation is playing and has a matching `beatsPerAnim` setting, the animation will be synchronised. 104 | -------------------------------------------------------------------------------- /docs/img/hues_420.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_420.png -------------------------------------------------------------------------------- /docs/img/hues_cowbell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_cowbell.png -------------------------------------------------------------------------------- /docs/img/hues_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_default.png -------------------------------------------------------------------------------- /docs/img/hues_hlwn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_hlwn.png -------------------------------------------------------------------------------- /docs/img/hues_montegral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_montegral.png -------------------------------------------------------------------------------- /docs/img/hues_snoop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_snoop.png -------------------------------------------------------------------------------- /docs/img/hues_xmas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/docs/img/hues_xmas.png -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/favicon.ico -------------------------------------------------------------------------------- /fonts/HuesExtra.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/fonts/HuesExtra.eot -------------------------------------------------------------------------------- /fonts/HuesExtra.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /fonts/HuesExtra.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/fonts/HuesExtra.ttf -------------------------------------------------------------------------------- /fonts/HuesExtra.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/fonts/HuesExtra.woff -------------------------------------------------------------------------------- /fonts/PetMe64.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/fonts/PetMe64.woff -------------------------------------------------------------------------------- /img/bones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/bones.png -------------------------------------------------------------------------------- /img/left-hand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/left-hand.png -------------------------------------------------------------------------------- /img/lightbase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/lightbase.png -------------------------------------------------------------------------------- /img/lightoff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/lightoff.png -------------------------------------------------------------------------------- /img/lightoff_inverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/lightoff_inverted.png -------------------------------------------------------------------------------- /img/lighton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/lighton.png -------------------------------------------------------------------------------- /img/lighton_inverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/lighton_inverted.png -------------------------------------------------------------------------------- /img/right-hand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/right-hand.png -------------------------------------------------------------------------------- /img/skull-eyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/skull-eyes.png -------------------------------------------------------------------------------- /img/skull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/skull.png -------------------------------------------------------------------------------- /img/tombstone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/tombstone.png -------------------------------------------------------------------------------- /img/tombstone_invert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/tombstone_invert.png -------------------------------------------------------------------------------- /img/vignette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/vignette.png -------------------------------------------------------------------------------- /img/web-bottomright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/web-bottomright.png -------------------------------------------------------------------------------- /img/web-topleft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/web-topleft.png -------------------------------------------------------------------------------- /img/web-topright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/web-topright.png -------------------------------------------------------------------------------- /img/wiresbottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/wiresbottom.png -------------------------------------------------------------------------------- /img/wiresleft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/wiresleft.png -------------------------------------------------------------------------------- /img/wiresright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mon/0x40-web/0e13374935185b499701792bfd80484c4614f296/img/wiresright.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0x40 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 43 | 44 | 45 | This page requires Javascript. 46 | 47 | 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "0x40-web", 3 | "version": "5.4", 4 | "description": "Pretty images and colours", 5 | "main": "index.html", 6 | "directories": { 7 | "doc": "docs" 8 | }, 9 | "scripts": { 10 | "format": "prettier --write ./**/*.svelte ./**/*.css ./**/*.ts" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/mon/0x40-web.git" 15 | }, 16 | "author": "mon", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/mon/0x40-web/issues" 20 | }, 21 | "homepage": "https://github.com/mon/0x40-web#readme", 22 | "devDependencies": { 23 | "@tsconfig/svelte": "^3.0.0", 24 | "css-loader": "^6.7.3", 25 | "esbuild-loader": "^3.0.1", 26 | "mini-css-extract-plugin": "^2.7.3", 27 | "prettier": "^2.8.4", 28 | "prettier-plugin-svelte": "^2.9.0", 29 | "source-map-loader": "^4.0.1", 30 | "style-loader": "^3.3.1", 31 | "svelte": "^3.56.0", 32 | "svelte-check": "^3.1.2", 33 | "svelte-check-plugin": "^1.0.4", 34 | "svelte-loader": "^3.1.5", 35 | "svelte-preprocess": "^5.0.1", 36 | "ts-loader": "^9.4.2", 37 | "typescript": "^4.9.5", 38 | "webpack": "^5.76.1", 39 | "webpack-cli": "^5.0.1", 40 | "webpack-dev-server": "^4.11.1" 41 | }, 42 | "dependencies": { 43 | "@types/string_score": "^0.1.28", 44 | "@wasm-audio-decoders/ogg-vorbis": "^0.1.16", 45 | "@zip.js/zip.js": "^2.6.78", 46 | "codec-parser": "^2.5.0", 47 | "mpg123-decoder": "^1.0.0", 48 | "ogg-opus-decoder": "^1.6.14", 49 | "string_score": "^0.1.22", 50 | "xmlbuilder": "^15.1.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /respack_edit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0x40 Respack Editor 7 | 8 | 9 | 10 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/css/hues-main.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "PetMe64Web"; 3 | font-style: normal; 4 | font-weight: normal; 5 | -webkit-font-smoothing: none; 6 | font-smooth: never; 7 | src: url("../../fonts/PetMe64.woff") format("woff"); 8 | } 9 | 10 | @font-face { 11 | font-family: "icomoon"; 12 | src: url("../../fonts/HuesExtra.eot?gmxg3s"); 13 | src: url("../../fonts/HuesExtra.eot?gmxg3s#iefix") format("embedded-opentype"), 14 | url("../../fonts/HuesExtra.ttf?gmxg3s") format("truetype"), 15 | url("../../fonts/HuesExtra.woff?gmxg3s") format("woff"), 16 | url("../../fonts/HuesExtra.svg?gmxg3s#icomoon") format("svg"); 17 | font-weight: normal; 18 | font-style: normal; 19 | } 20 | 21 | .hues-root { 22 | height: 100%; 23 | margin: 0; 24 | padding: 0; 25 | overflow: hidden; 26 | font-family: "PetMe64Web"; 27 | position: relative; 28 | background-color: transparent; 29 | } 30 | 31 | .hues-root h1, 32 | .hues-root h2, 33 | .hues-root h3 { 34 | text-align: center; 35 | } 36 | 37 | .hues-root h1 { 38 | font-size: 15pt; 39 | } 40 | 41 | .hues-root h2 { 42 | font-size: 10pt; 43 | } 44 | 45 | .hues-root h3 { 46 | font-size: 7pt; 47 | } 48 | 49 | .hidden { 50 | display: none !important; 51 | } 52 | 53 | .invisible { 54 | visibility: hidden !important; 55 | } 56 | 57 | .hues-icon { 58 | /* use !important to prevent issues with browser extensions that change fonts */ 59 | font-family: "icomoon" !important; 60 | speak: none; 61 | font-style: normal; 62 | font-weight: normal; 63 | font-variant: normal; 64 | text-transform: none; 65 | line-height: 1; 66 | 67 | /* Better Font Rendering =========== */ 68 | -webkit-font-smoothing: antialiased; 69 | -moz-osx-font-smoothing: grayscale; 70 | } 71 | 72 | .hues-canvas { 73 | position: absolute; 74 | top: 0; 75 | left: 0; 76 | display: block; 77 | height: 100%; 78 | padding: 0; 79 | z-index: -10; 80 | background-color: white; 81 | } 82 | 83 | .hues-visualiser { 84 | position: absolute; 85 | z-index: -1; 86 | } 87 | 88 | .hues-preloader { 89 | /* first 2 colours are the loaded colour, next 2 are unloaded */ 90 | background: linear-gradient(to right, #fff 0%, #fff 50%, #ddd 50%, #ddd 100%); 91 | background-size: 200% 100%; 92 | background-position: 100% 0; 93 | 94 | width: 100%; 95 | height: 100%; 96 | display: flex; 97 | justify-content: center; 98 | align-items: center; 99 | flex-direction: column; 100 | font-size: 25pt; 101 | 102 | position: absolute; 103 | top: 0; 104 | left: 0; 105 | z-index: 10; 106 | visibility: visible; 107 | opacity: 1; 108 | transition: visibility 1s linear, opacity 1s linear, 109 | background-position 0.5s ease; 110 | } 111 | 112 | .hues-preloader--loaded { 113 | visibility: hidden; 114 | opacity: 0; 115 | } 116 | 117 | .hues-preloader__title { 118 | /* Just ballpark it and hope the given title isn't super long */ 119 | font-size: min(30pt, calc(100vw / 15)); 120 | } 121 | 122 | .hues-preloader__text { 123 | /* "Initialising..." is 15 chars long, clamp to the viewport width */ 124 | font-size: min(25pt, calc(100vw / 15)); 125 | display: block; 126 | text-align: center; 127 | } 128 | 129 | .hues-preloader__subtext { 130 | /* "Tap or click to start" is 21 chars long, clamp to the viewport width */ 131 | font-size: min(12pt, calc(100vw / 21)); 132 | text-align: center; 133 | } 134 | 135 | .hues-preloader__subtext span { 136 | /* 8pt is sufficiently small to not worry about clamping */ 137 | font-size: 8pt; 138 | opacity: 0.7; 139 | } 140 | 141 | .hues-ui { 142 | /* from 0.0 to 1.0 */ 143 | --invert: 0; 144 | } 145 | 146 | .unstyled-link { 147 | color: inherit; 148 | text-decoration: none; 149 | } 150 | 151 | .hues-button { 152 | font-size: 10px; 153 | margin: 3px 2px; 154 | padding: 3px; 155 | background-color: rgba(127, 127, 127, 0.5); 156 | border-color: rgb(0, 0, 0); 157 | border-width: 1px; 158 | border-style: solid; 159 | cursor: pointer; 160 | /* Don't want double click to select */ 161 | -webkit-touch-callout: none; 162 | -webkit-user-select: none; 163 | -khtml-user-select: none; 164 | -moz-user-select: none; 165 | -ms-user-select: none; 166 | user-select: none; 167 | } 168 | 169 | .hues-button--loaded { 170 | background-color: rgba(0, 127, 0, 0.5); 171 | cursor: default; 172 | } 173 | 174 | .hues-button--disabled { 175 | color: #777; 176 | cursor: default; 177 | } 178 | 179 | .hues-button:hover { 180 | background: rgba(255, 255, 255, 0.5); 181 | } 182 | 183 | .hues-button--loaded:hover { 184 | background-color: rgba(0, 127, 0, 0.5); 185 | cursor: default; 186 | } 187 | 188 | .hues-button--disabled:hover { 189 | background-color: rgba(127, 127, 127, 0.5); 190 | } 191 | 192 | .hues-button--glow { 193 | animation-name: glow; 194 | animation-duration: 2s; 195 | animation-iteration-count: infinite; 196 | } 197 | 198 | @keyframes glow { 199 | from { 200 | background-color: rgba(127, 127, 127, 0.5); 201 | } 202 | 50% { 203 | background-color: rgba(0, 127, 0, 0.5); 204 | } 205 | to { 206 | background-color: rgba(127, 127, 127, 0.5); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/css/hues-respacks.css: -------------------------------------------------------------------------------- 1 | .respacks { 2 | display: flex; 3 | box-sizing: border-box; 4 | width: 640px; 5 | 6 | margin: 5px; 7 | height: 400px; 8 | font-size: 14px; 9 | } 10 | 11 | .respacks__manager, 12 | .respacks__display { 13 | box-sizing: border-box; 14 | display: flex; 15 | flex-direction: column; 16 | } 17 | 18 | .respacks__manager { 19 | width: 40%; 20 | margin-right: 5px; 21 | } 22 | 23 | .respacks__display { 24 | width: 60%; 25 | margin-left: 5px; 26 | } 27 | 28 | .respacks__header { 29 | padding: 5px 0; 30 | flex-shrink: 0; 31 | } 32 | 33 | .resource-list { 34 | flex-grow: 1; 35 | 36 | border: 2px solid black; 37 | background: rgba(255, 255, 255, 0.3); 38 | overflow: auto; 39 | overflow-x: hidden; 40 | } 41 | 42 | .resource-list--fill { 43 | height: 100%; 44 | } 45 | 46 | .respacks-listitem { 47 | font-size: 10px; 48 | border-bottom: 1px solid black; 49 | display: flex; 50 | align-items: center; 51 | } 52 | 53 | .respacks-listitem > span { 54 | display: block; 55 | width: 100%; 56 | height: 100%; 57 | padding: 2px; 58 | 59 | cursor: pointer; 60 | } 61 | 62 | .respacks-listitem :hover { 63 | background: rgba(255, 255, 255, 0.5); 64 | } 65 | 66 | .respacks-listitem input[type="checkbox"] { 67 | display: none; 68 | } 69 | 70 | .respacks-listitem > label { 71 | content: ""; 72 | 73 | width: 12px; 74 | height: 10px; 75 | margin: auto 2px; 76 | 77 | background-color: #ccc; 78 | border: 1px solid black; 79 | cursor: pointer; 80 | } 81 | 82 | .respacks-listitem input[type="checkbox"]:before { 83 | border-radius: 3px; 84 | } 85 | 86 | .respacks-listitem input[type="checkbox"]:checked + label { 87 | background-color: #222; 88 | text-align: center; 89 | line-height: 15px; 90 | } 91 | 92 | .respacks-buttons { 93 | flex-shrink: 0; 94 | display: flex; 95 | justify-content: space-between; 96 | padding: 0; 97 | } 98 | 99 | .respacks-buttons--fill > .hues-button { 100 | flex-grow: 1; 101 | text-align: center; 102 | } 103 | 104 | .respacks-bottom-container { 105 | height: 35px; 106 | } 107 | 108 | .progress-container { 109 | height: 35px; 110 | font-size: 11px; 111 | } 112 | 113 | .progress-bar { 114 | height: 5px; /* Can be anything */ 115 | position: relative; 116 | background: #000; 117 | border-radius: 25px; 118 | padding: 2px; 119 | margin: 2px; 120 | } 121 | 122 | .progress-bar--filled { 123 | display: block; 124 | height: 100%; 125 | border-radius: 8px; 126 | background-color: rgb(43, 194, 83); 127 | position: relative; 128 | overflow: hidden; 129 | } 130 | 131 | .stat-text { 132 | flex-shrink: 0; 133 | display: flex; 134 | justify-content: space-between; 135 | margin: 0 5px; 136 | font-size: 9px; 137 | } 138 | 139 | .respack-description { 140 | flex-shrink: 0; 141 | border: 3px solid gray; 142 | background: rgba(255, 255, 255, 0.5); 143 | font-size: 9px; 144 | margin: 2px; 145 | padding: 2px; 146 | } 147 | 148 | .respack-tab-container { 149 | flex-shrink: 0; 150 | display: flex; 151 | width: 100%; 152 | } 153 | 154 | .respack-tab { 155 | box-sizing: border-box; 156 | border: 2px solid black; 157 | padding: 5px; 158 | cursor: pointer; 159 | /* Actually wider than the container, but has a centering effect */ 160 | width: 50%; 161 | } 162 | 163 | .respack-tab--checked { 164 | background: rgba(255, 255, 255, 0.3); 165 | border-bottom: none; 166 | } 167 | 168 | .respack-tab:hover { 169 | background: rgba(255, 255, 255, 0.3); 170 | } 171 | 172 | .respack-tab__content { 173 | display: none; 174 | border-top: none; 175 | } 176 | 177 | .respack-tab__content--checked { 178 | display: block; 179 | } 180 | 181 | .respacks-count-container { 182 | flex-shrink: 0; 183 | display: flex; 184 | justify-content: space-between; 185 | } 186 | 187 | .respacks-enabledsongs, 188 | .respacks-enabledimages { 189 | display: block; 190 | position: absolute; 191 | bottom: 0; 192 | right: 0; 193 | max-height: 150px; 194 | overflow: auto; 195 | } 196 | 197 | .respacks-enabledsongs { 198 | width: 515px; 199 | } 200 | 201 | .respacks-enabledimages { 202 | width: 315px; 203 | } 204 | 205 | /* smol screens */ 206 | @media (max-width: 700px) { 207 | .respacks-enabledsongs { 208 | left: 0; 209 | width: auto; 210 | } 211 | } 212 | @media (max-width: 400px) { 213 | .respacks-enabledimages { 214 | left: 0; 215 | width: auto; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/css/hues-settings.css: -------------------------------------------------------------------------------- 1 | .hues-win-helper { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | position: absolute; 6 | margin-top: -15px; 7 | width: 100%; 8 | height: 100%; 9 | } 10 | 11 | .hues-win { 12 | position: relative; 13 | z-index: 9; 14 | max-height: calc(100% - 120px); 15 | margin: 10px; 16 | overflow-y: auto; 17 | overflow-x: hidden; 18 | 19 | background: rgba(200, 200, 200, 0.7); 20 | border-color: black; 21 | border-width: 2px; 22 | border-style: solid; 23 | } 24 | 25 | @media (max-width: 768px) { 26 | .hues-win { 27 | /* to account for modern UI */ 28 | max-height: calc(100% - 190px); 29 | } 30 | } 31 | 32 | .hues-win__closebtn { 33 | height: 20px; 34 | width: 20px; 35 | font-size: 20px; 36 | color: white; 37 | position: absolute; 38 | right: 0; 39 | background-color: rgb(128, 128, 128); 40 | border: 1px solid black; 41 | cursor: pointer; 42 | } 43 | 44 | .hues-win__tabs { 45 | margin: -1px; 46 | padding-top: 22px; 47 | display: flex; 48 | /* looks pretty shit when they do wrap, but the alternative is an unusable UI */ 49 | flex-wrap: wrap; 50 | } 51 | 52 | .tab-label { 53 | flex-grow: 1; 54 | cursor: pointer; 55 | padding: 10px; 56 | border: 2px solid black; 57 | text-align: center; 58 | } 59 | 60 | .tab-label--active { 61 | border-bottom: 0; 62 | } 63 | 64 | l.tab-label:hover { 65 | background: rgba(255, 255, 255, 0.3); 66 | } 67 | 68 | .tab-content { 69 | display: none; 70 | } 71 | 72 | .tab-content--active { 73 | display: block; 74 | } 75 | 76 | .hues-win__closebtn:hover { 77 | background-color: rgb(200, 200, 200); 78 | } 79 | 80 | .hues-win__closebtn:after { 81 | content: "x"; 82 | } 83 | -------------------------------------------------------------------------------- /src/css/huesUI-hlwn.css: -------------------------------------------------------------------------------- 1 | /* HalloweenUI */ 2 | 3 | .hues-h-text { 4 | /* red to cyan, but do it through RGB to avoid rainbows */ 5 | color: rgb( 6 | calc(255 - var(--invert) * 255), 7 | calc(51 + var(--invert) * 153), 8 | calc(0 + var(--invert) * 255) 9 | ); 10 | } 11 | 12 | .hues-preloader.hues-h-text { 13 | background: linear-gradient(to right, #000 0%, #000 50%, #222 50%, #222 100%); 14 | background-size: 200% 100%; 15 | background-position: 100% 0; 16 | } 17 | 18 | .hues-h-textfade { 19 | color: rgba( 20 | calc(255 - var(--invert) * 255), 21 | calc(51 + var(--invert) * 153), 22 | calc(0 + var(--invert) * 255), 23 | 0.6 24 | ); 25 | } 26 | 27 | .hues-m-beatbar.hues-h-beatbar { 28 | border-style: none; 29 | background: none; 30 | overflow: inherit; 31 | } 32 | 33 | .hues-m-beatcenter.hues-h-text { 34 | background: none; 35 | top: 0; 36 | width: 42px; 37 | height: 43px; 38 | box-shadow: none; 39 | padding-top: 21px; 40 | z-index: 1; 41 | } 42 | .hues-m-beatcenter.hues-h-skull { 43 | background-image: url("../../img/skull.png"); 44 | z-index: 0; 45 | opacity: calc(1 - var(--invert)); 46 | } 47 | .hues-m-beatcenter.hues-h-skull.inverted { 48 | background-position: -42px 0; 49 | opacity: var(--invert); 50 | } 51 | 52 | .hues-m-beatcenter.hues-h-text > span { 53 | font-size: 13px; 54 | } 55 | 56 | .hues-m-beatcenter.hues-h-text.hues-ui--hidden { 57 | transform: translateY(-80px); 58 | } 59 | 60 | .hues-h-eyes { 61 | background: none; 62 | background-image: url("../../img/skull-eyes.png"); 63 | top: 0; 64 | width: 42px; 65 | height: 64px; 66 | box-shadow: none; 67 | 68 | animation-duration: 150ms; 69 | animation-name: hues-h-beatcenter; 70 | animation-fill-mode: forwards; 71 | } 72 | .inverted.hues-h-eyes { 73 | /* Set again to override the other .inverted selector from modern */ 74 | background: none; 75 | background-image: url("../../img/skull-eyes.png"); 76 | box-shadow: none; 77 | background-position: -42px 0; 78 | animation-name: hues-h-beatcenter-invert; 79 | } 80 | 81 | @keyframes hues-h-beatcenter { 82 | from { 83 | opacity: calc(1 - var(--invert)); 84 | } 85 | 50% { 86 | opacity: calc(1 - var(--invert)); 87 | } 88 | to { 89 | opacity: 0; 90 | } 91 | } 92 | @keyframes hues-h-beatcenter-invert { 93 | from { 94 | opacity: var(--invert); 95 | } 96 | 50% { 97 | opacity: var(--invert); 98 | } 99 | to { 100 | opacity: 0; 101 | } 102 | } 103 | 104 | .hues-h-left-hand { 105 | background: url("../../img/left-hand.png"); 106 | left: -15px; 107 | } 108 | 109 | .hues-h-right-hand { 110 | background: url("../../img/right-hand.png"); 111 | right: -15px; 112 | } 113 | 114 | .hues-h-left-hand, 115 | .hues-h-right-hand { 116 | width: 63px; 117 | height: 42px; 118 | position: absolute; 119 | background-repeat: no-repeat; 120 | opacity: calc(1 - var(--invert)); 121 | } 122 | .inverted.hues-h-left-hand, 123 | .inverted.hues-h-right-hand { 124 | background-position: -63px 0; 125 | opacity: var(--invert); 126 | } 127 | 128 | .hues-m-controls.hues-h-controls { 129 | background: none; 130 | border-style: none; 131 | padding-top: 8px; 132 | } 133 | 134 | @media (min-width: 768px) { 135 | .hues-m-controls.hues-h-controls.hues-ui--hidden { 136 | transform: translateY(64px); 137 | } 138 | } 139 | 140 | .hues-m-songtitle.hues-h-text, 141 | .hues-m-imagename.hues-h-text { 142 | padding: 4px 0; 143 | margin: 0 5px; 144 | background: none; 145 | /* border-style: solid; 146 | border-width: 0 19px 0 18px; 147 | border-image: url(../../img/bones.png) 29 19 0 18 fill repeat stretch; */ 148 | } 149 | /* cheeky hacks to fade over invert */ 150 | .hues-m-songtitle.hues-h-text::before, 151 | .hues-m-imagename.hues-h-text::before, 152 | .hues-m-songtitle.hues-h-text::after, 153 | .hues-m-imagename.hues-h-text::after { 154 | content: ""; 155 | position: absolute; 156 | left: 0; 157 | top: 0; 158 | right: 0; 159 | bottom: 0; 160 | z-index: -1; 161 | opacity: calc(1 - var(--invert)); 162 | border-style: solid; 163 | border-width: 0 19px 0 18px; 164 | border-image: url(../../img/bones.png) 29 19 0 18 fill repeat stretch; 165 | } 166 | .hues-m-songtitle.hues-h-text::after, 167 | .hues-m-imagename.hues-h-text::after { 168 | opacity: var(--invert); 169 | border-image-slice: 0 19 29 18 fill; 170 | } 171 | 172 | .hues-m-huename.hues-h-text { 173 | border: none; 174 | background: none; 175 | left: 38px; 176 | right: 38px; 177 | bottom: 2px; 178 | } 179 | 180 | .hues-m-vol-bar.hues-h-vol-bar { 181 | bottom: 13px; 182 | } 183 | 184 | .hues-m-vol-label.hues-h-text { 185 | bottom: 12px; 186 | } 187 | 188 | .hues-m-hide.hues-h-text { 189 | top: 40px; 190 | } 191 | 192 | .hues-m-cog.hues-h-text { 193 | top: 18px; 194 | } 195 | 196 | .hues-m-question.hues-h-text { 197 | top: 25px; 198 | } 199 | 200 | .hues-m-songbutton.hues-h-text, 201 | .hues-m-imagebutton.hues-h-text { 202 | margin-top: 17px; 203 | } 204 | 205 | .hues-m-songbutton.hues-h-text + div, 206 | .hues-m-imagebutton.hues-h-text + div { 207 | top: -8px; 208 | } 209 | 210 | .hues-m-prevbutton.hues-h-text, 211 | .hues-m-nextbutton.hues-h-text, 212 | .hues-m-actbutton.hues-h-text { 213 | background: none; 214 | } 215 | 216 | .hues-h-controls > .hues-m-leftinfo, 217 | .hues-h-controls > .hues-m-rightinfo { 218 | margin-bottom: 5px; 219 | } 220 | 221 | .hues-h-tombstone { 222 | height: 36px; 223 | position: absolute; 224 | bottom: 0; 225 | left: 0; 226 | right: 0; 227 | z-index: -10; 228 | 229 | border-style: solid; 230 | border-width: 22px 40px 0 42px; 231 | border-image: url(../../img/tombstone.png) 22 42 0 fill stretch; 232 | opacity: calc(1 - var(--invert)); 233 | } 234 | .inverted.hues-h-tombstone { 235 | border-image: url(../../img/tombstone_invert.png) 22 42 0 fill stretch; 236 | opacity: var(--invert); 237 | } 238 | 239 | .hues-h-text + input[type="range"]::-webkit-slider-runnable-track { 240 | background: rgb( 241 | calc(255 - var(--invert) * 255), 242 | calc(51 + var(--invert) * 153), 243 | calc(0 + var(--invert) * 255) 244 | ); 245 | } 246 | .hues-h-text + input[type="range"]::-webkit-slider-thumb { 247 | background: rgb( 248 | calc(255 - var(--invert) * 255), 249 | calc(51 + var(--invert) * 153), 250 | calc(0 + var(--invert) * 255) 251 | ); 252 | } 253 | .hues-h-text + input[type="range"]::-moz-range-track { 254 | background: rgb( 255 | calc(255 - var(--invert) * 255), 256 | calc(51 + var(--invert) * 153), 257 | calc(0 + var(--invert) * 255) 258 | ); 259 | } 260 | .hues-h-text + input[type="range"]::-moz-range-thumb { 261 | background: rgb( 262 | calc(255 - var(--invert) * 255), 263 | calc(51 + var(--invert) * 153), 264 | calc(0 + var(--invert) * 255) 265 | ); 266 | } 267 | .hues-h-text + input[type="range"]::-ms-fill-lower { 268 | background: rgb( 269 | calc(255 - var(--invert) * 255), 270 | calc(51 + var(--invert) * 153), 271 | calc(0 + var(--invert) * 255) 272 | ); 273 | } 274 | .hues-h-text + input[type="range"]::-ms-thumb { 275 | background: rgb( 276 | calc(255 - var(--invert) * 255), 277 | calc(51 + var(--invert) * 153), 278 | calc(0 + var(--invert) * 255) 279 | ); 280 | } 281 | 282 | .hues-h-topleft, 283 | .hues-h-topright, 284 | .hues-h-bottomright { 285 | position: absolute; 286 | background-repeat: no-repeat; 287 | z-index: -9; 288 | } 289 | 290 | .hues-h-topleft, 291 | .hues-h-topright { 292 | top: 0; 293 | } 294 | 295 | .hues-h-bottomright, 296 | .hues-h-topright { 297 | right: 0; 298 | } 299 | 300 | .hues-h-topleft { 301 | background-image: url("../../img/web-topleft.png"); 302 | width: 269px; 303 | height: 237px; 304 | opacity: calc(1 - var(--invert)); 305 | } 306 | .hues-h-topleft.inverted { 307 | background-position: -269px 0; 308 | opacity: var(--invert); 309 | } 310 | 311 | .hues-h-topright { 312 | background-image: url("../../img/web-topright.png"); 313 | width: 215px; 314 | height: 220px; 315 | opacity: calc(1 - var(--invert)); 316 | } 317 | .hues-h-topright.inverted { 318 | background-position: -215px 0; 319 | opacity: var(--invert); 320 | } 321 | 322 | .hues-h-bottomright { 323 | background-image: url("../../img/web-bottomright.png"); 324 | bottom: 0; 325 | width: 358px; 326 | height: 284px; 327 | opacity: calc(1 - var(--invert)); 328 | } 329 | .hues-h-bottomright.inverted { 330 | background-position: -358px 0; 331 | opacity: var(--invert); 332 | } 333 | 334 | .hues-h-vignette { 335 | background-image: url("../../img/vignette.png"); 336 | background-size: 100% 100%; 337 | width: 100%; 338 | height: 100%; 339 | position: absolute; 340 | z-index: -1; 341 | } 342 | -------------------------------------------------------------------------------- /src/css/huesUI-modern.css: -------------------------------------------------------------------------------- 1 | /* ModernUI, heavily based on Kepstin's wonderful CSS work 2 | https://github.com/kepstin/0x40hues-html5/blob/master/hues-m.css */ 3 | 4 | .hues-m-beatbar, 5 | .hues-m-beatcenter { 6 | transform: translateY(0px); 7 | transition: transform 1s ease-out; 8 | } 9 | 10 | .hues-m-controls { 11 | transform: translateY(0px); 12 | transition: transform 1s ease-out; 13 | } 14 | 15 | .hues-m-beatbar.hues-ui--hidden, 16 | .hues-m-beatcenter.hues-ui--hidden { 17 | transform: translateY(-40px); 18 | } 19 | 20 | .hues-m-controls.hues-ui--hidden { 21 | transform: translateY(108px); 22 | } 23 | 24 | .hues-m-visualisercontainer { 25 | position: absolute; 26 | width: 100%; 27 | height: 64px; 28 | bottom: 108px; 29 | left: -8px; 30 | right: 0; 31 | margin: 0 auto; 32 | } 33 | 34 | .hues-m-beatbar { 35 | position: absolute; 36 | top: 0; 37 | max-width: 992px; 38 | height: 30px; 39 | margin: 0 auto; 40 | overflow: hidden; 41 | left: 8px; 42 | right: 8px; 43 | color: white; 44 | /* grey to white */ 45 | background: hsla(0, 0%, calc(50% + var(--invert) * 50%), 0.5); 46 | border-color: rgba(0, 0, 0, 0.5); 47 | border-width: 0 4px 4px; 48 | border-style: solid; 49 | } 50 | 51 | .hues-m-beatleft, 52 | .hues-m-beatright, 53 | .hues-m-songtitle, 54 | .hues-m-imagename, 55 | .hues-m-huename { 56 | /* white to black */ 57 | color: hsl(0, 0%, calc(100% - var(--invert) * 100%)); 58 | /* black to white */ 59 | background: hsla(0, 0%, calc(var(--invert) * 100%), 0.7); 60 | height: 20px; 61 | line-height: 20px; 62 | font-size: 12px; 63 | overflow: hidden; 64 | white-space: nowrap; 65 | border-radius: 10px; 66 | } 67 | 68 | .hues-m-leftinfo, 69 | .hues-m-rightinfo { 70 | position: absolute; 71 | font-size: 10px; 72 | text-align: center; 73 | /* white to black */ 74 | color: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 75 | bottom: 79px; 76 | width: 100px; 77 | } 78 | 79 | .hues-m-leftinfo { 80 | left: 8px; 81 | } 82 | 83 | .hues-m-rightinfo { 84 | right: 8px; 85 | } 86 | 87 | .hues-m-huename { 88 | font-size: 8px; 89 | height: 12px; 90 | line-height: 12px; 91 | border-radius: 10px; 92 | } 93 | 94 | .hues-m-beatleft, 95 | .hues-m-beatright { 96 | position: absolute; 97 | padding: 0 0 0 20px; 98 | top: 5px; 99 | overflow: hidden; 100 | border-radius: 0 10px 10px 0; 101 | } 102 | .hues-m-beatleft { 103 | transform: scaleX(-1); 104 | left: 8px; 105 | right: 50%; 106 | } 107 | .hues-m-beatright { 108 | left: 50%; 109 | right: 8px; 110 | } 111 | 112 | .hues-m-beatcenter { 113 | position: absolute; 114 | top: -6px; 115 | left: 0; 116 | right: 0; 117 | margin: 0 auto; 118 | height: 40px; 119 | width: 40px; 120 | /* white to black */ 121 | color: hsl(0, 0%, calc(100% - var(--invert) * 100%)); 122 | /* grey to less grey */ 123 | background: hsl(0, 0%, calc(31% + var(--invert) * 38%)); 124 | font-size: 20px; 125 | line-height: 40px; 126 | border-radius: 20px; 127 | text-align: center; 128 | /* black to white */ 129 | box-shadow: inset 0 0 12px hsla(0, 0%, calc(var(--invert) * 100%), 0.5); 130 | } 131 | 132 | .hues-m-beatcenter > span { 133 | animation-duration: 150ms; 134 | animation-name: hues-m-beatcenter; 135 | animation-fill-mode: forwards; 136 | } 137 | @keyframes hues-m-beatcenter { 138 | from { 139 | opacity: 1; 140 | } 141 | 50% { 142 | opacity: 1; 143 | } 144 | to { 145 | opacity: 0; 146 | } 147 | } 148 | 149 | .hues-m-controls { 150 | position: absolute; 151 | bottom: 0; 152 | max-width: 992px; 153 | height: 104px; 154 | margin: 0 auto; 155 | left: 8px; 156 | right: 8px; 157 | /* white to black */ 158 | color: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 159 | background: rgba(127, 127, 127, 0.5); 160 | /* half grey to white */ 161 | border-color: hsla(0, 0%, calc(50% + var(--invert) * 50%), 0.5); 162 | border-width: 4px 4px 0; 163 | border-style: solid; 164 | } 165 | 166 | .hues-m-songtitle, 167 | .hues-m-imagename, 168 | .hues-m-huename { 169 | position: absolute; 170 | text-align: center; 171 | padding: 0 4px; 172 | left: 8px; 173 | right: 8px; 174 | } 175 | .hues-m-songtitle { 176 | bottom: 55px; 177 | } 178 | .hues-m-imagename { 179 | bottom: 79px; 180 | left: 108px; 181 | right: 108px; 182 | } 183 | 184 | .hues-m-songtitle > a:link, 185 | .hues-m-songtitle > a:visited, 186 | .hues-m-imagename > a:link, 187 | .hues-m-imagename > a:visited { 188 | color: inherit; 189 | text-decoration: none; 190 | } 191 | 192 | .hues-m-songtitle > a.small, 193 | .hues-m-imagename > a.small { 194 | font-size: 10px; 195 | } 196 | 197 | .hues-m-songtitle > a.x-small, 198 | .hues-m-imagename > a.x-small { 199 | font-size: 8px; 200 | } 201 | 202 | .hues-m-leftbox { 203 | position: absolute; 204 | bottom: 0; 205 | left: 0; 206 | right: 50%; 207 | height: 54px; 208 | } 209 | 210 | .hues-m-rightbox { 211 | position: absolute; 212 | bottom: 0; 213 | left: 50%; 214 | right: 0; 215 | height: 54px; 216 | } 217 | 218 | .hues-m-controlblock { 219 | /* Don't want double click to select */ 220 | -webkit-touch-callout: none; 221 | -webkit-user-select: none; 222 | -khtml-user-select: none; 223 | -moz-user-select: none; 224 | -ms-user-select: none; 225 | user-select: none; 226 | font-size: 12px; 227 | width: 50%; 228 | height: 100%; 229 | margin: 3px auto; 230 | float: left; 231 | position: relative; 232 | } 233 | 234 | .hues-m-controlbuttons { 235 | margin: auto; 236 | position: relative; 237 | width: 70px; 238 | } 239 | 240 | .hues-m-songbutton { 241 | cursor: pointer; 242 | text-align: center; 243 | } 244 | 245 | .hues-m-prevbutton, 246 | .hues-m-nextbutton, 247 | .hues-m-actbutton { 248 | position: absolute; 249 | cursor: pointer; 250 | } 251 | 252 | .hues-m-prevbutton:hover, 253 | .hues-m-nextbutton:hover, 254 | .hues-m-actbutton:hover { 255 | /* grey to less grey */ 256 | background: hsl(0, 0%, calc(39% + var(--invert) * 30%)); 257 | } 258 | 259 | .hues-m-prevbutton, 260 | .hues-m-nextbutton { 261 | /* white to black */ 262 | color: hsl(0, 0%, calc(100% - var(--invert) * 100%)); 263 | /* grey to less grey */ 264 | background: hsl(0, 0%, calc(16% + var(--invert) * 68%)); 265 | height: 20px; 266 | line-height: 20px; 267 | font-size: 12px; 268 | white-space: nowrap; 269 | border-radius: 10px; 270 | top: 7.5px; 271 | } 272 | 273 | .hues-m-prevbutton { 274 | padding: 0 10px 0 0; 275 | left: 5px; 276 | border-radius: 10px 0 0 10px; 277 | } 278 | 279 | .hues-m-nextbutton { 280 | padding: 0 0 0 10px; 281 | left: 42px; 282 | border-radius: 0 10px 10px 0; 283 | } 284 | 285 | .hues-m-actbutton { 286 | height: 35px; 287 | width: 35px; 288 | left: 17.5px; 289 | /* white to black */ 290 | color: hsl(0, 0%, calc(100% - var(--invert) * 100%)); 291 | /* grey to less grey */ 292 | background: hsl(0, 0%, calc(13% + var(--invert) * 74%)); 293 | font-size: 20px; 294 | line-height: 35px; 295 | border-radius: 20px; 296 | text-align: center; 297 | z-index: 1; 298 | } 299 | 300 | .hues-m-huename { 301 | bottom: 5px; 302 | } 303 | 304 | .hues-m-question, 305 | .hues-m-cog, 306 | .hues-m-hide { 307 | cursor: pointer; 308 | } 309 | 310 | .hues-m-cog { 311 | position: absolute; 312 | left: 14px; 313 | top: 1px; 314 | font-size: 20px; 315 | } 316 | 317 | .hues-m-hide { 318 | position: absolute; 319 | left: 15px; 320 | top: 22px; 321 | font-size: 15px; 322 | } 323 | 324 | .hues-m-hiderestore { 325 | display: none; 326 | position: absolute; 327 | left: 8px; 328 | right: 8px; 329 | bottom: 0; 330 | margin: 0 auto; 331 | height: 30px; 332 | max-width: 992px; 333 | background: rgba(0, 0, 0, 0); 334 | border-top-left-radius: 100px; 335 | border-top-right-radius: 100px; 336 | cursor: pointer; 337 | } 338 | 339 | .hues-m-hiderestore:hover { 340 | background: linear-gradient( 341 | hsla(0, 0%, calc(var(--invert) * 100%), 0), 342 | hsla(0, 0%, calc(var(--invert) * 100%), 0.4) 343 | ); 344 | } 345 | 346 | .hues-m-hiderestore.hues-ui--hidden { 347 | display: block; 348 | } 349 | 350 | .hues-m-question { 351 | position: absolute; 352 | right: 8px; 353 | top: 8px; 354 | font-size: 25px; 355 | } 356 | 357 | .hues-m-vol-bar { 358 | position: absolute; 359 | height: 20px; 360 | bottom: 21px; 361 | left: 40px; 362 | right: 40px; 363 | } 364 | 365 | .hues-m-vol-label { 366 | display: block; 367 | position: absolute; 368 | left: 0; 369 | bottom: 14px; 370 | right: 0; 371 | height: 12px; 372 | color: inherit; 373 | font: inherit; 374 | font-size: 12px; 375 | line-height: 12px; 376 | text-align: center; 377 | padding: 0; 378 | width: 100%; 379 | background: transparent; 380 | border: none; 381 | cursor: pointer; 382 | } 383 | .hues-m-vol-bar > input { 384 | display: block; 385 | position: absolute; 386 | left: 0; 387 | bottom: 0; 388 | right: 0; 389 | height: 12px; 390 | } 391 | 392 | .hues-m-listcontainer { 393 | position: absolute; 394 | right: 8px; 395 | left: 8px; 396 | bottom: 110px; 397 | z-index: 1; /* put it in front of xmas UI lights */ 398 | } 399 | 400 | /* Fun slider stuff! */ 401 | 402 | input.hues-m-range[type="range"] { 403 | width: 100%; 404 | margin: 0; 405 | padding: 0; 406 | height: 12px; 407 | background: transparent; 408 | -moz-appearance: none; 409 | -webkit-appearance: none; 410 | } 411 | 412 | input.hues-m-range[type="range"]::-webkit-slider-runnable-track { 413 | width: 100%; 414 | height: 4px; 415 | /* white to black */ 416 | background: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 417 | border: none; 418 | border-radius: 0; 419 | } 420 | 421 | input.hues-m-range[type="range"]::-webkit-slider-thumb { 422 | -moz-appearance: none; 423 | -webkit-appearance: none; 424 | box-shadow: none; 425 | border: none; 426 | height: 12px; 427 | width: 4px; 428 | border-radius: 0; 429 | background: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 430 | margin-top: -4px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */ 431 | } 432 | 433 | input.hues-m-range[type="range"]::-moz-range-track { 434 | width: 100%; 435 | height: 4px; 436 | background: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 437 | border: none; 438 | border-radius: 0; 439 | } 440 | 441 | input.hues-m-range[type="range"]::-moz-range-thumb { 442 | box-shadow: none; 443 | border: none; 444 | height: 12px; 445 | width: 4px; 446 | border-radius: 0; 447 | background: hsl(0, 0%, calc(100% - var(--invert) * 100%)); 448 | } 449 | 450 | input.hues-m-range[type="range"]::-ms-track { 451 | width: 100%; 452 | background: transparent; /* Hides the slider so custom styles can be added */ 453 | border-color: transparent; 454 | color: transparent; 455 | height: 4px; 456 | border-width: 4px 0; 457 | } 458 | 459 | input.hues-m-range[type="range"]::-ms-fill-lower { 460 | background: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 461 | } 462 | input.hues-m-range[type="range"]::-ms-fill-upper { 463 | background: hsla(0, 0%, calc(100% - var(--invert) * 100%), 0.7); 464 | } 465 | 466 | input.hues-m-range[type="range"]::-ms-thumb { 467 | box-shadow: none; 468 | border: none; 469 | height: 12px; 470 | width: 4px; 471 | border-radius: 0; 472 | background: hsl(0, 0%, calc(100% - var(--invert) * 100%)); 473 | } 474 | 475 | @media (min-width: 768px) { 476 | .hues-m-controls { 477 | height: 54px; 478 | } 479 | .hues-m-controls.hues-ui--hidden { 480 | transform: translateY(58px); 481 | } 482 | .hues-m-imagename { 483 | left: 300px; 484 | right: 300px; 485 | bottom: 29px; 486 | } 487 | .hues-m-songtitle { 488 | left: 192px; 489 | right: 192px; 490 | bottom: 5px; 491 | } 492 | .hues-m-leftinfo { 493 | left: 200px; 494 | } 495 | .hues-m-rightinfo { 496 | right: 200px; 497 | } 498 | .hues-m-leftinfo, 499 | .hues-m-rightinfo { 500 | bottom: 29px; 501 | } 502 | .hues-m-leftbox { 503 | left: 0; 504 | right: auto; 505 | width: 192px; 506 | height: 54px; 507 | } 508 | .hues-m-rightbox { 509 | left: auto; 510 | right: 0; 511 | width: 192px; 512 | height: 54px; 513 | } 514 | .hues-m-listcontainer { 515 | bottom: 60px; 516 | max-width: 992px; 517 | margin: 0 auto; 518 | } 519 | .hues-m-visualisercontainer { 520 | bottom: 58px; 521 | } 522 | } 523 | -------------------------------------------------------------------------------- /src/css/huesUI-retro.css: -------------------------------------------------------------------------------- 1 | /* RetroUI */ 2 | 3 | .RetroUI { 4 | /* from black to white */ 5 | color: hsl(0, 0%, calc(var(--invert) * 100%)); 6 | } 7 | 8 | .hues-r-container { 9 | position: absolute; 10 | bottom: 0; 11 | white-space: nowrap; 12 | overflow: hidden; 13 | width: 100%; 14 | font-size: 10px; 15 | } 16 | 17 | .hues-r-container a:link, 18 | .hues-r-container a:visited { 19 | display: block; 20 | color: inherit; 21 | text-decoration: none; 22 | overflow: hidden; 23 | } 24 | 25 | .hues-r-controls { 26 | display: flex; 27 | align-items: center; 28 | position: absolute; 29 | right: 0; 30 | bottom: 10px; 31 | font-size: 30px; 32 | } 33 | 34 | .hues-r-button { 35 | float: left; 36 | cursor: pointer; 37 | text-align: center; 38 | opacity: 0.5; 39 | } 40 | 41 | .hues-r-button:hover { 42 | opacity: 1; 43 | } 44 | 45 | .hues-r-songs { 46 | font-size: 13px; 47 | margin: 0 -8px; 48 | } 49 | 50 | .hues-r-manualmode, 51 | .hues-r-automode { 52 | float: none; 53 | clear: both; 54 | } 55 | 56 | .hues-r-manualmode { 57 | font-size: 15px; 58 | } 59 | 60 | .hues-r-automode { 61 | font-size: 10px; 62 | } 63 | 64 | .hues-r-subcontrols { 65 | position: absolute; 66 | right: 0; 67 | bottom: 40px; 68 | font-size: 25px; 69 | text-align: center; 70 | } 71 | 72 | .hues-r-subcontrols > div { 73 | margin: 3px; 74 | cursor: pointer; 75 | opacity: 0.5; 76 | } 77 | 78 | .hues-r-subcontrols > div:hover { 79 | opacity: 1; 80 | } 81 | 82 | .hues-r-hiderestore { 83 | position: absolute; 84 | bottom: 5px; 85 | right: 5px; 86 | display: none; 87 | font-size: 25px; 88 | cursor: pointer; 89 | opacity: 0.3; 90 | } 91 | 92 | .hues-r-hiderestore.hues-ui--hidden { 93 | display: block; 94 | } 95 | 96 | .hues-r-hiderestore:hover { 97 | opacity: 0.8; 98 | } 99 | 100 | .hues-r-container, 101 | .hues-r-controls, 102 | .hues-r-subcontrols { 103 | visibility: inherit; 104 | opacity: 1; 105 | transition: visibility 0.5s linear, opacity 0.5s linear; 106 | } 107 | 108 | .hues-r-container.hues-ui--hidden, 109 | .hues-r-controls.hues-ui--hidden, 110 | .hues-r-subcontrols.hues-ui--hidden { 111 | visibility: hidden; 112 | opacity: 0; 113 | } 114 | 115 | .hues-r-listcontainer { 116 | position: absolute; 117 | right: 35px; 118 | bottom: 45px; 119 | } 120 | 121 | .hues-r-visualisercontainer { 122 | transform: scaleY(-1); 123 | position: absolute; 124 | width: 100%; 125 | height: 64px; 126 | top: 0; 127 | left: 0; 128 | } 129 | 130 | @media (max-width: 700px) { 131 | .hues-r-listcontainer { 132 | left: 130px; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/css/huesUI-weed.css: -------------------------------------------------------------------------------- 1 | /* WeedUI */ 2 | 3 | .WeedUI { 4 | /* from black to white */ 5 | color: hsl(0, 0%, calc(var(--invert) * 100%)); 6 | } 7 | 8 | .hues-w-controls { 9 | display: flex; 10 | align-items: center; 11 | position: absolute; 12 | right: 0; 13 | bottom: 0; 14 | font-size: 30px; 15 | } 16 | 17 | .hues-w-subcontrols { 18 | position: absolute; 19 | right: 0; 20 | bottom: 30px; 21 | font-size: 25px; 22 | text-align: center; 23 | } 24 | 25 | .hues-w-subcontrols > div { 26 | margin: 3px; 27 | cursor: pointer; 28 | opacity: 0.5; 29 | } 30 | 31 | .hues-w-subcontrols > div:hover { 32 | opacity: 1; 33 | } 34 | 35 | .hues-w-controls, 36 | .hues-w-subcontrols, 37 | .hues-w-beatbar { 38 | visibility: inherit; 39 | opacity: 1; 40 | transition: visibility 0.5s linear, opacity 0.5s linear; 41 | } 42 | 43 | .hues-w-controls.hues-ui--hidden, 44 | .hues-w-subcontrols.hues-ui--hidden, 45 | .hues-w-beatbar.hues-ui--hidden { 46 | visibility: hidden; 47 | opacity: 0; 48 | } 49 | 50 | .hues-w-beatleft, 51 | .hues-w-beatright { 52 | font-size: 13px; 53 | position: absolute; 54 | padding: 0 0 0 5px; 55 | top: 5px; 56 | overflow: hidden; 57 | border-radius: 0 10px 10px 0; 58 | white-space: nowrap; 59 | } 60 | .hues-w-beatleft { 61 | transform: scaleX(-1); 62 | left: 8px; 63 | right: 50%; 64 | } 65 | .hues-w-beatright { 66 | left: 50%; 67 | right: 8px; 68 | } 69 | 70 | .hues-w-beataccent { 71 | position: absolute; 72 | left: 0; 73 | right: 0; 74 | margin-left: auto; 75 | margin-right: auto; 76 | margin-top: 15px; 77 | text-align: center; 78 | font-size: 35px; 79 | opacity: 0; 80 | /* from grey to not so grey */ 81 | text-shadow: hsl(0, 0%, calc(40% + var(--invert) * 20%)); 82 | text-shadow: -2px 2px 0 #666; 83 | 84 | animation-name: fallspin; 85 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); 86 | animation-duration: 0.5s; 87 | } 88 | 89 | @keyframes fallspin { 90 | from { 91 | transform: rotate(0deg) translate(0, 0); 92 | opacity: 1; 93 | } 94 | } 95 | 96 | .hues-r-visualisercontainer.hues-w-visualisercontainer { 97 | top: 17px; 98 | } 99 | -------------------------------------------------------------------------------- /src/css/huesUI-xmas.css: -------------------------------------------------------------------------------- 1 | /* XmasUI */ 2 | 3 | .hues-m-controls.hues-x-controls { 4 | z-index: 1; 5 | } 6 | 7 | .hues-x-snow { 8 | z-index: -9; 9 | background-color: transparent; 10 | } 11 | 12 | .hues-m-beatbar.hues-x-beatbar { 13 | background: none; 14 | border-style: none; 15 | overflow: visible; 16 | } 17 | 18 | .hues-x-lightbox { 19 | position: absolute; 20 | width: 68px; 21 | height: 113px; 22 | transform-origin: 32px 69px; 23 | } 24 | .hues-x-light { 25 | position: absolute; 26 | width: 100%; 27 | height: 100%; 28 | background-image: url("../../img/lightbase.png"); 29 | background-position: 0 0; 30 | background-repeat: no-repeat; 31 | opacity: calc(1 - var(--invert)); 32 | } 33 | .hues-x-light.inverted { 34 | background-position: -68px 0; 35 | opacity: var(--invert); 36 | } 37 | 38 | .hues-x-fade { 39 | transition: opacity 0.1s linear; 40 | } 41 | 42 | .hues-x-lighton, 43 | .hues-x-lightoff { 44 | position: absolute; 45 | width: 56px; 46 | height: 81px; 47 | left: 5px; 48 | top: 9px; 49 | background-repeat: no-repeat; 50 | } 51 | 52 | .hues-x-lighton { 53 | background-image: url("../../img/lighton.png"); 54 | opacity: calc(1 - var(--invert)); 55 | } 56 | .hues-x-lighton.inverted { 57 | background-image: url("../../img/lighton_inverted.png"); 58 | opacity: var(--invert); 59 | } 60 | 61 | .hues-x-lightoff { 62 | opacity: 0; 63 | background-image: url("../../img/lightoff.png"); 64 | } 65 | .hues-x-lightoff.inverted { 66 | background-image: url("../../img/lightoff_inverted.png"); 67 | } 68 | 69 | .hues-x-lighton.off { 70 | opacity: 0; 71 | } 72 | 73 | .hues-x-lightoff.off { 74 | opacity: calc(1 - var(--invert)); 75 | } 76 | 77 | .hues-x-lightoff.off.inverted { 78 | opacity: var(--invert); 79 | } 80 | 81 | .hues-x-wiresleft, 82 | .hues-x-wiresbottom, 83 | .hues-x-wiresright { 84 | position: absolute; 85 | } 86 | 87 | .hues-x-wiresleft, 88 | .hues-x-wiresright { 89 | height: 100%; 90 | width: 200px; 91 | top: 0; 92 | overflow: hidden; 93 | } 94 | 95 | .hues-x-wiresleft { 96 | left: 0; 97 | } 98 | .hues-x-wiresleft::before { 99 | width: 60px; 100 | height: 1435px; 101 | background-image: url("../../img/wiresleft.png"); 102 | opacity: calc(1 - var(--invert)); 103 | } 104 | .hues-x-wiresleft.inverted::before { 105 | background-position: -60px 0; 106 | opacity: var(--invert); 107 | } 108 | 109 | .hues-x-wiresright { 110 | right: 0; 111 | } 112 | .hues-x-wiresright::before { 113 | right: 0; 114 | width: 58px; 115 | height: 1261px; 116 | background-image: url("../../img/wiresright.png"); 117 | opacity: calc(1 - var(--invert)); 118 | } 119 | .hues-x-wiresright.inverted::before { 120 | background-position: -58px 0; 121 | opacity: var(--invert); 122 | } 123 | 124 | .hues-x-wiresbottomhelper { 125 | position: absolute; 126 | bottom: 0; 127 | width: 100%; 128 | height: 200px; 129 | overflow: hidden; 130 | } 131 | 132 | .hues-x-wiresbottom { 133 | height: 200px; 134 | width: 2621px; 135 | left: 50%; 136 | margin-left: -1310.5px; 137 | overflow: hidden; 138 | } 139 | .hues-x-wiresbottom::before { 140 | bottom: 0; 141 | width: 2621px; 142 | height: 49px; 143 | background-image: url("../../img/wiresbottom.png"); 144 | background-position: 127px -49px; 145 | opacity: calc(1 - var(--invert)); 146 | } 147 | .hues-x-wiresbottom.inverted::before { 148 | background-position: 127px 0; 149 | opacity: var(--invert); 150 | } 151 | 152 | .hues-x-wiresleft::before, 153 | .hues-x-wiresbottom::before, 154 | .hues-x-wiresright::before { 155 | content: ""; 156 | position: absolute; 157 | z-index: -1; 158 | background-repeat: no-repeat; 159 | } 160 | 161 | .hues-x-visualisercontainer { 162 | transform: scaleY(-1); 163 | position: absolute; 164 | width: 100%; 165 | height: 64px; 166 | top: 25px; 167 | left: 0; 168 | } 169 | -------------------------------------------------------------------------------- /src/js/Components/HuesButton.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | 30 | 108 | -------------------------------------------------------------------------------- /src/js/EventListener.ts: -------------------------------------------------------------------------------- 1 | interface Event { 2 | [ev: string]: (...args: any[]) => any; 3 | } 4 | 5 | export default class EventListener { 6 | listeners: Partial<{ 7 | // each event gets an array of event handlers 8 | [ev in keyof Events]: Set; 9 | }>; 10 | 11 | constructor() { 12 | this.listeners = {}; 13 | } 14 | 15 | callEventListeners(ev: E, ...args: any) { 16 | if (!(ev in this.listeners)) { 17 | return; 18 | } 19 | 20 | let ret = undefined; 21 | for (const callback of this.listeners[ev]!) { 22 | const callbackRet = callback(...args); 23 | if (callbackRet !== undefined) { 24 | ret = callbackRet; 25 | } 26 | } 27 | 28 | return ret; 29 | } 30 | 31 | addEventListener(ev: E, callback: Events[E]) { 32 | if (!(ev in this.listeners)) { 33 | this.listeners[ev] = new Set(); 34 | } 35 | this.listeners[ev]!.add(callback); 36 | } 37 | 38 | removeEventListener(ev: E, callback: Events[E]) { 39 | if (!(ev in this.listeners)) { 40 | return; 41 | } 42 | this.listeners[ev]!.delete(callback); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/js/HuesCanvas2D.ts: -------------------------------------------------------------------------------- 1 | // HTML5 canvas backend for HuesRender 2 | 3 | import { 4 | type RenderParams, 5 | type HuesCanvas, 6 | calculateImageDrawCoords, 7 | } from "./HuesRender"; 8 | import type { SettingsData } from "./HuesSettings"; 9 | import { mixColours, intToHex } from "./Utils"; 10 | 11 | // can't just use CanvasImageSource since some of the options (SVG stuff) don't 12 | // have width/height 13 | type Drawable = HTMLImageElement | HTMLCanvasElement | undefined; 14 | 15 | /* Takes root DOM element to attach to */ 16 | export default class HuesCanvas2D implements HuesCanvas { 17 | root: HTMLElement; 18 | 19 | baseHeight: number; 20 | 21 | blurIterations!: number; 22 | blurDelta!: number; 23 | blurAlpha!: number; 24 | blurFinalAlpha!: number; 25 | 26 | invertEverything!: boolean; 27 | 28 | trippyRadius: number; 29 | shutterWidth: number; 30 | 31 | canvas: HTMLCanvasElement; 32 | context: CanvasRenderingContext2D; 33 | 34 | // these may be better suited as arrays but somehow I can visualise this better 35 | offCanvas: HTMLCanvasElement; 36 | offContext: CanvasRenderingContext2D; 37 | offCanvas2: HTMLCanvasElement; 38 | offContext2: CanvasRenderingContext2D; 39 | offCanvas3: HTMLCanvasElement; 40 | offContext3: CanvasRenderingContext2D; 41 | 42 | constructor(root: HTMLElement, height = 720) { 43 | this.root = root; 44 | 45 | this.baseHeight = height; 46 | 47 | this.trippyRadius = 0; 48 | 49 | // so it can be modified at runtime by aspiring people 50 | this.shutterWidth = 1; 51 | 52 | // Chosen because it looks decent 53 | this.setBlurQuality("high"); 54 | 55 | // matches the flash 56 | this.setInvertStyle("everything"); 57 | 58 | this.canvas = document.createElement("canvas"); 59 | // marked as never-null because if this fails, you're screwed 60 | this.context = this.canvas.getContext("2d")!; 61 | this.canvas.className = "hues-canvas"; 62 | root.appendChild(this.canvas); 63 | 64 | this.offCanvas = document.createElement("canvas"); 65 | this.offContext = this.offCanvas.getContext("2d")!; 66 | 67 | this.offCanvas2 = document.createElement("canvas"); 68 | this.offContext2 = this.offCanvas2.getContext("2d")!; 69 | 70 | this.offCanvas3 = document.createElement("canvas"); 71 | this.offContext3 = this.offCanvas3.getContext("2d")!; 72 | } 73 | 74 | get width() { 75 | return this.canvas.width; 76 | } 77 | 78 | get height() { 79 | return this.canvas.height; 80 | } 81 | 82 | setInvertStyle(style: SettingsData["invertStyle"]) { 83 | this.invertEverything = style === "everything"; 84 | } 85 | 86 | setBlurQuality(quality: SettingsData["blurQuality"]) { 87 | this.blurIterations = { low: -1, medium: 11, high: 19, extreme: 35 }[ 88 | quality 89 | ]; 90 | // you might be thinking "hey aren't you approximating a gaussian 91 | // blur, shouldn't this be not constant?" and you would be right, 92 | // but HTML canvas does not support additive alpha operations, only 93 | // multiplicative. As a result, once the image merges fully 94 | // together, you get an opacity that isn't quite fully there. It'd 95 | // be better to invest effort into a GPU renderer that *can* support 96 | // additive alpha, than work out a clean algorithm to properly fix 97 | // this (because it won't work properly on greyscale anyway, I 98 | // think...) 99 | this.blurDelta = 1 / (this.blurIterations / 2); 100 | this.blurAlpha = 1 / (this.blurIterations / 2); 101 | // because, again, premultiplied alpha, the final stack isn't fully 102 | // opaque. To avoid a "pop" as the non-blurred render kicks in, we 103 | // actually render at this opacity. Sucks, but whatever... Worse case is 104 | // "extreme" which renders at 87% final opacity. 105 | this.blurFinalAlpha = 1 - Math.pow(1 - this.blurAlpha, this.blurIterations); 106 | // but low quality can just use full alpha 107 | if (this.blurIterations == -1) { 108 | this.blurFinalAlpha = 1; 109 | } 110 | } 111 | 112 | resize() { 113 | // height is clamped, we expand width to suit 114 | let height = this.root.clientHeight; 115 | let ratio = this.root.clientWidth / height; 116 | this.canvas.height = Math.min(height, this.baseHeight); 117 | this.canvas.width = Math.ceil(this.canvas.height * ratio); 118 | this.offCanvas.height = this.canvas.height; 119 | this.offCanvas.width = this.canvas.width; 120 | this.offCanvas2.height = this.canvas.height; 121 | this.offCanvas2.width = this.canvas.width; 122 | this.offCanvas3.height = this.canvas.height; 123 | this.offCanvas3.width = this.canvas.width; 124 | // to fill a square to the edges 125 | this.trippyRadius = 126 | (Math.max(this.canvas.width, this.canvas.height) / 2) * Math.SQRT2; 127 | } 128 | 129 | draw(params: RenderParams) { 130 | let width = this.canvas.width; 131 | let height = this.canvas.height; 132 | 133 | // white BG for the hard light filter 134 | this.context.globalAlpha = 1; 135 | this.context.globalCompositeOperation = "source-over"; 136 | 137 | // optimise the draw 138 | if (params.overlayPercent >= 1) { 139 | this.drawOverlay( 140 | params.overlayPercent, 141 | params.overlayColour, 142 | params.invert 143 | ); 144 | return; 145 | } 146 | 147 | // might be doing a clipping region for shutter 148 | this.context.save(); 149 | 150 | this.context.globalAlpha = 1; 151 | this.context.globalCompositeOperation = "source-over"; 152 | 153 | if (params.bgColour === "transparent") { 154 | this.context.clearRect(0, 0, width, height); 155 | } else { 156 | this.context.fillStyle = intToHex(params.bgColour); 157 | this.context.fillRect(0, 0, width, height); 158 | } 159 | 160 | if (params.shutter !== undefined) { 161 | let vertical; 162 | let reverse; 163 | 164 | switch (params.shutterDir) { 165 | case "→": 166 | reverse = false; 167 | vertical = false; 168 | break; 169 | case "←": 170 | reverse = true; 171 | vertical = false; 172 | break; 173 | case "↑": 174 | reverse = true; 175 | vertical = true; 176 | break; 177 | case "↓": 178 | reverse = false; 179 | vertical = true; 180 | break; 181 | } 182 | 183 | let full; 184 | if (vertical) { 185 | full = height + this.shutterWidth; 186 | } else { 187 | full = width + this.shutterWidth; 188 | } 189 | 190 | let edge = Math.floor(full * params.shutter); 191 | if (reverse) { 192 | edge = full - edge; 193 | } 194 | 195 | // we need to save these as arrays for handling the firefox bug below 196 | let region1: [number, number, number, number]; 197 | let region2: [number, number, number, number]; 198 | if (vertical) { 199 | region1 = [0, edge, width, full - edge]; 200 | region2 = [0, 0, width, edge - this.shutterWidth]; 201 | } else { 202 | region1 = [edge, 0, full - edge, height]; 203 | region2 = [0, 0, edge - this.shutterWidth, height]; 204 | } 205 | 206 | if (reverse) { 207 | let tmp = region1; 208 | region1 = region2; 209 | region2 = tmp; 210 | } 211 | 212 | // make the shutter itself black 213 | this.context.fillStyle = "#000"; 214 | if (vertical) { 215 | this.context.fillRect( 216 | 0, 217 | edge - this.shutterWidth, 218 | width, 219 | this.shutterWidth 220 | ); 221 | } else { 222 | this.context.fillRect( 223 | edge - this.shutterWidth, 224 | 0, 225 | this.shutterWidth, 226 | height 227 | ); 228 | } 229 | 230 | // clip the underlay image and draw it 231 | this.context.save(); 232 | const path1 = new Path2D(); 233 | path1.rect(...region1); 234 | this.context.clip(path1); 235 | 236 | this.drawBitmap( 237 | params.lastBitmap, 238 | params.lastBitmapAlign, 239 | width, 240 | height, 241 | params.slices, 242 | params.xBlur, 243 | params.yBlur, 244 | params.invert, 245 | params.lastBitmapCenter, 246 | params.border, 247 | params.centerLine 248 | ); 249 | 250 | this.drawColour( 251 | params.lastColour, 252 | params.blendMode, 253 | params.bgColour, 254 | params.outTrippy, 255 | params.inTrippy, 256 | width, 257 | height 258 | ); 259 | 260 | this.context.restore(); 261 | 262 | // Firefox bug: somehow the white background we draw leaks outside of the 263 | // first clipping region, we need to explicitly clear the pixels for 264 | // transparent backgrounds to work properly 265 | // TODO: report this to Mozilla 266 | if (params.bgColour === "transparent") { 267 | this.context.clearRect(...region2); 268 | } 269 | 270 | // clip the overlay and continue 271 | const path2 = new Path2D(); 272 | path2.rect(...region2); 273 | this.context.clip(path2); 274 | } 275 | 276 | this.drawBitmap( 277 | params.bitmap, 278 | params.bitmapAlign, 279 | width, 280 | height, 281 | params.slices, 282 | params.xBlur, 283 | params.yBlur, 284 | params.invert, 285 | params.bitmapCenter, 286 | params.border, 287 | params.centerLine 288 | ); 289 | 290 | const colour = 291 | params.colourFade !== undefined 292 | ? mixColours(params.lastColour, params.colour, params.colourFade) 293 | : params.colour; 294 | 295 | this.drawColour( 296 | colour, 297 | params.blendMode, 298 | params.bgColour, 299 | params.outTrippy, 300 | params.inTrippy, 301 | width, 302 | height 303 | ); 304 | 305 | // all operations after this affect the entire image 306 | this.context.restore(); 307 | 308 | if (params.invert && this.invertEverything) { 309 | this.drawInvert(params.invert); 310 | } 311 | 312 | if (params.overlayPercent > 0) { 313 | this.drawOverlay( 314 | params.overlayPercent, 315 | params.overlayColour, 316 | params.invert 317 | ); 318 | } 319 | } 320 | 321 | drawOverlay(percent: number, colour: number, invert: number) { 322 | // If we draw the overlay and then invert, and the overlay and invert 323 | // percent are identical, and the overlay colour is white, the invert 324 | // actually cancels itself out... So we always draw this with a precomputed 325 | // invert colour and do any "real" inverts beforehand 326 | this.context.globalCompositeOperation = "source-over"; 327 | this.context.globalAlpha = percent; 328 | this.context.fillStyle = intToHex(mixColours(colour, ~colour, invert)); 329 | this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); 330 | } 331 | 332 | drawBitmap( 333 | bitmap: Drawable, 334 | bitmapAlign: RenderParams["bitmapAlign"], 335 | width: number, 336 | height: number, 337 | slices: RenderParams["slices"], 338 | xBlur: number, 339 | yBlur: number, 340 | invert: number, 341 | bitmapCenter?: number, 342 | borders?: boolean, 343 | centerLine?: boolean 344 | ) { 345 | if (!bitmap) { 346 | return; 347 | } 348 | 349 | let [x, _y, drawWidth, drawHeight, scaledBitmapCenter] = 350 | calculateImageDrawCoords( 351 | width, 352 | height, 353 | bitmap.width, 354 | bitmap.height, 355 | bitmapAlign, 356 | bitmapCenter 357 | ); 358 | 359 | // the debugging draws have to happen last, but these are modified in between 360 | const origHeight = drawHeight; 361 | const origWidth = drawWidth; 362 | const origX = x; 363 | 364 | // invert image-only if needed, correctly handling cursed transparency 365 | // see drawColour for more information 366 | if (invert && !this.invertEverything) { 367 | // invert layer 368 | this.offContext3.globalCompositeOperation = "copy"; 369 | this.offContext3.fillStyle = intToHex(mixColours(0, 0xffffff, invert)); 370 | this.offContext3.fillRect(0, 0, this.canvas.width, this.canvas.height); 371 | // mask with image 372 | this.offContext3.globalCompositeOperation = "destination-in"; 373 | this.offContext3.drawImage(bitmap, x, 0, drawWidth, drawHeight); 374 | 375 | // perform invert for real 376 | this.offContext3.globalCompositeOperation = "difference"; 377 | this.offContext3.drawImage(bitmap, x, 0, drawWidth, drawHeight); 378 | 379 | // since the bitmap is replaced with a correctly offset and scaled version 380 | drawWidth = width; 381 | drawHeight = height; 382 | x = 0; 383 | 384 | bitmap = this.offCanvas3; 385 | } 386 | 387 | if (slices) { 388 | bitmap = this.drawSlice( 389 | slices, 390 | bitmap, 391 | x, 392 | drawWidth, 393 | drawHeight, 394 | width, 395 | height 396 | ); 397 | // since the bitmap is replaced with a correctly offset and scaled version 398 | drawWidth = width; 399 | drawHeight = height; 400 | x = 0; 401 | } 402 | 403 | if (xBlur || yBlur) { 404 | this.drawBlur(bitmap, x, drawWidth, drawHeight, xBlur, yBlur); 405 | } else { 406 | this.context.globalAlpha = this.blurFinalAlpha; 407 | this.context.drawImage(bitmap, x, 0, drawWidth, drawHeight); 408 | } 409 | 410 | // debug stuff 411 | if (borders) { 412 | this.context.strokeStyle = "#f00"; 413 | this.context.lineWidth = 1; 414 | this.context.strokeRect(origX, 0, origWidth, origHeight); 415 | } 416 | if (centerLine && scaledBitmapCenter !== undefined) { 417 | const center = origX + scaledBitmapCenter; 418 | this.context.strokeStyle = "#0f0"; 419 | this.context.lineWidth = 1; 420 | // this produces 2 lines sometimes for some reason 421 | // this.context.strokeRect(center, 0, center, origHeight); 422 | this.context.beginPath(); 423 | this.context.moveTo(center, 0); 424 | this.context.lineTo(center, origHeight); 425 | this.context.stroke(); 426 | } 427 | } 428 | 429 | drawColour( 430 | colour: number, 431 | blendMode: GlobalCompositeOperation, 432 | bgColour: number | "transparent", 433 | outTrippy: number | undefined, 434 | inTrippy: number | undefined, 435 | width: number, 436 | height: number 437 | ) { 438 | if (outTrippy !== undefined || inTrippy !== undefined) { 439 | this.drawTrippy(outTrippy, inTrippy, colour, width, height); 440 | } else { 441 | this.offContext.fillStyle = intToHex(colour); 442 | this.offContext.fillRect(0, 0, width, height); 443 | } 444 | 445 | if (bgColour !== "transparent") { 446 | // sane draw 447 | this.context.globalAlpha = 0.7; 448 | this.context.globalCompositeOperation = blendMode; 449 | this.context.drawImage(this.offCanvas, 0, 0); 450 | } else { 451 | // so basically, HTML canvas blend modes act nothing like what you 452 | // would expect when you start using alpha with them. If you try and 453 | // fade invert the image later, the first frame of the invert, where 454 | // the difference layer is opaque black pixels (theoretically a 455 | // no-op), actually dims the entire background by a significant 456 | // amount. So we need to copy the look of the transparent 457 | // background, but make the image totally opaque so our filters 458 | // work. This is done by: 459 | // - Isolating the pixels *without* the image, and drawing the 460 | // colour over a white background with no blend 461 | // - Isolating the pixels *with* the image, and blending the colour 462 | // as normal 463 | // At this point in the code, we now have: 464 | // - context: the image (sliced and/or blurred) with transparency 465 | // - offContext: the colour (maybe trippy) 466 | // So we can use offContext2 and offContext3 as scratch space 467 | 468 | // backup the image 469 | this.offContext3.globalCompositeOperation = "copy"; 470 | this.offContext3.drawImage(this.canvas, 0, 0); 471 | // re-add white background to main canvas 472 | this.context.globalAlpha = 1; 473 | this.context.globalCompositeOperation = "destination-over"; 474 | this.context.fillStyle = "#fff"; 475 | this.context.fillRect(0, 0, width, height); 476 | 477 | this.context.globalAlpha = 0.7; 478 | this.context.globalCompositeOperation = blendMode; 479 | 480 | // create colour only where the image is 481 | this.offContext2.globalAlpha = 1; 482 | this.offContext2.globalCompositeOperation = "copy"; 483 | this.offContext2.drawImage(this.offCanvas, 0, 0); 484 | this.offContext2.globalCompositeOperation = "destination-in"; 485 | this.offContext2.drawImage(this.offCanvas3, 0, 0); 486 | // draw this with the right blend 487 | this.context.drawImage(this.offCanvas2, 0, 0); 488 | 489 | // create colour only where the image *isn't* 490 | this.offContext2.globalCompositeOperation = "copy"; 491 | this.offContext2.drawImage(this.offCanvas, 0, 0); 492 | this.offContext2.globalCompositeOperation = "destination-out"; 493 | this.offContext2.drawImage(this.offCanvas3, 0, 0); 494 | // draw this with no blend 495 | this.context.globalCompositeOperation = "source-over"; 496 | this.context.drawImage(this.offCanvas2, 0, 0); 497 | } 498 | } 499 | 500 | drawInvert(invert: number) { 501 | this.context.globalAlpha = 1; 502 | this.context.globalCompositeOperation = "difference"; 503 | this.context.fillStyle = intToHex(mixColours(0, 0xffffff, invert)); 504 | 505 | this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); 506 | } 507 | 508 | drawSlice( 509 | _slices: RenderParams["slices"], 510 | bitmap: Drawable, 511 | offset: number, 512 | drawWidth: number, 513 | drawHeight: number, 514 | width: number, 515 | height: number 516 | ) { 517 | this.offContext.clearRect(0, 0, width, height); 518 | if (!bitmap) { 519 | return this.offCanvas; 520 | } 521 | 522 | // since we always call this with valid slice data 523 | const slices = _slices!; 524 | 525 | let bitmapXOffset = 0; 526 | let drawXOffset = offset; 527 | for (let i = 0; i < slices.x.count; i++) { 528 | let xSegment = slices.x.segments[i]; 529 | let sliceXDistance = 530 | slices.x.distances[i] * slices.x.percent * this.canvas.width; 531 | let segmentBitmapWidth = Math.ceil(xSegment * bitmap.width); 532 | let segmentDrawWidth = Math.ceil(xSegment * drawWidth); 533 | 534 | let bitmapYOffset = 0; 535 | let drawYOffset = 0; 536 | for (let j = 0; j < slices.y.count; j++) { 537 | let ySegment = slices.y.segments[j]; 538 | let sliceYDistance = 539 | slices.y.distances[j] * slices.y.percent * this.canvas.width; 540 | let segmentBitmapHeight = Math.ceil(ySegment * bitmap.height); 541 | let segmentDrawHeight = Math.ceil(ySegment * drawHeight); 542 | 543 | this.offContext.drawImage( 544 | bitmap, 545 | bitmapXOffset, 546 | bitmapYOffset, // subsection x, y 547 | segmentBitmapWidth, 548 | segmentBitmapHeight, // subsection w, h 549 | drawXOffset + sliceYDistance, 550 | drawYOffset + sliceXDistance, // drawn x, y 551 | segmentDrawWidth, 552 | segmentDrawHeight 553 | ); // drawn w, h 554 | 555 | bitmapYOffset += segmentBitmapHeight; 556 | drawYOffset += segmentDrawHeight; 557 | } 558 | 559 | bitmapXOffset += segmentBitmapWidth; 560 | drawXOffset += segmentDrawWidth; 561 | } 562 | 563 | return this.offCanvas; 564 | } 565 | 566 | drawBlur( 567 | _bitmap: Drawable, 568 | offset: number, 569 | drawWidth: number, 570 | drawHeight: number, 571 | xBlur: number, 572 | yBlur: number 573 | ) { 574 | let bitmap = _bitmap!; // only ever called with valid data 575 | if (this.blurIterations < 0) { 576 | // "LOW" blur quality is special - just warps the images 577 | // extra little oomph to make it more obvious 578 | let xDist = xBlur * this.baseHeight * 1.5; 579 | let yDist = yBlur * this.baseHeight * 1.5; 580 | 581 | this.context.globalAlpha = 1; 582 | this.context.drawImage( 583 | bitmap, 584 | Math.round(offset - xDist / 2), 585 | Math.round(-yDist / 2), 586 | drawWidth + xDist, 587 | drawHeight + yDist 588 | ); 589 | } else { 590 | this.context.globalAlpha = this.blurAlpha; 591 | let dist; 592 | if (xBlur) { 593 | // have to use offCanvas/context2 here, because we might 594 | // have been passed the first offCanvas from the slice 595 | // effect 596 | let xContext = this.context; 597 | // do we even need the offCanvas? 598 | if (yBlur) { 599 | this.offContext2.globalAlpha = this.blurAlpha; 600 | this.offContext2.globalCompositeOperation = "source-over"; 601 | this.offContext2.clearRect( 602 | 0, 603 | 0, 604 | this.canvas.width, 605 | this.canvas.height 606 | ); 607 | xContext = this.offContext2; 608 | } 609 | 610 | // since blur is based on render height 611 | dist = xBlur * this.baseHeight; 612 | for (let i = -1; i <= 1; i += this.blurDelta) { 613 | xContext.drawImage( 614 | bitmap, 615 | Math.round(dist * i) + offset, 616 | 0, 617 | drawWidth, 618 | drawHeight 619 | ); 620 | } 621 | 622 | if (yBlur) { 623 | offset = 0; 624 | bitmap = this.offCanvas2; 625 | drawWidth = this.canvas.width; 626 | drawHeight = this.canvas.height; 627 | } 628 | } 629 | if (yBlur) { 630 | dist = yBlur * this.baseHeight; 631 | for (let i = -1; i <= 1; i += this.blurDelta) { 632 | this.context.drawImage( 633 | bitmap, 634 | offset, 635 | Math.round(dist * i), 636 | drawWidth, 637 | drawHeight 638 | ); 639 | } 640 | } 641 | } 642 | } 643 | 644 | // draws the correct trippy colour circles onto the offscreen canvas 645 | drawTrippy( 646 | outTrippy: number | undefined, 647 | inTrippy: number | undefined, 648 | colour: number, 649 | width: number, 650 | height: number 651 | ) { 652 | outTrippy = outTrippy === undefined ? 1 : outTrippy; 653 | inTrippy = inTrippy === undefined ? 0 : inTrippy; 654 | 655 | let trippyRadii; 656 | if (outTrippy > inTrippy) { 657 | trippyRadii = [outTrippy, inTrippy]; 658 | } else { 659 | trippyRadii = [inTrippy, outTrippy]; 660 | } 661 | 662 | let invertC = intToHex(0xffffff ^ colour); 663 | let normalC = intToHex(colour); 664 | this.offContext.fillStyle = invertC; 665 | this.offContext.fillRect(0, 0, width, height); 666 | 667 | let invert = false; 668 | for (let i = 0; i < 2; i++) { 669 | // Invert for each subsequent draw 670 | this.offContext.beginPath(); 671 | this.offContext.fillStyle = invert ? invertC : normalC; 672 | this.offContext.arc( 673 | width / 2, 674 | height / 2, 675 | Math.floor(trippyRadii[i]! * this.trippyRadius), 676 | 0, 677 | 2 * Math.PI, 678 | false 679 | ); 680 | this.offContext.fill(); 681 | this.offContext.closePath(); 682 | invert = !invert; 683 | } 684 | } 685 | } 686 | -------------------------------------------------------------------------------- /src/js/HuesEditor.ts: -------------------------------------------------------------------------------- 1 | import xmlbuilder from "xmlbuilder"; 2 | import * as zip from "@zip.js/zip.js"; 3 | 4 | import { HuesSong, Respack, type HuesSongSection } from "./ResourcePack"; 5 | import EditorMain from "./HuesEditor/Main.svelte"; 6 | import type { HuesCore } from "./HuesCore"; 7 | import type HuesWindow from "./HuesWindow"; 8 | import type EditorBoxSvelte from "./HuesEditor/EditorBox.svelte"; 9 | 10 | export interface EditorUndoRedo { 11 | builds?: string[]; 12 | loops?: string[]; 13 | independentBuild: boolean; 14 | caret?: number; 15 | editor?: EditorBoxSvelte; 16 | } 17 | 18 | type SectionName = "build" | "loop"; 19 | 20 | export class HuesEditor { 21 | core: HuesCore; 22 | song?: HuesSong; 23 | editor!: EditorMain; 24 | 25 | // for storing respacks created with "new" 26 | respack?: Respack; 27 | 28 | // to avoid recursion 29 | midUpdate!: boolean; 30 | 31 | constructor(core: HuesCore, huesWin: HuesWindow) { 32 | this.core = core; 33 | if (!core.settings.enableWindow) { 34 | return; 35 | } 36 | 37 | this.midUpdate = false; 38 | 39 | let container = huesWin.addTab("EDITOR"); 40 | this.editor = new EditorMain({ 41 | target: container, 42 | props: { 43 | huesRoot: this.core.root, 44 | soundManager: this.core.soundManager, 45 | // if the first window is the editor, the user doesn't want the extra click 46 | // but eh, maybe the performance impact really isn't that bad 47 | totallyDisabled: false, 48 | //totallyDisabled: this.core.settings.firstWindow != 'EDITOR', 49 | }, 50 | }); 51 | 52 | core.addEventListener("newsong", (song) => { 53 | if (this.midUpdate) { 54 | return; 55 | } 56 | 57 | this.song = song; 58 | this.editor.$set({ 59 | independentBuild: song?.independentBuild, 60 | title: song?.title, 61 | source: song?.source, 62 | loop: song?.loop, 63 | build: song?.build, 64 | undoQueue: song?.undoQueue, 65 | redoQueue: song?.redoQueue, 66 | hiddenBanks: song?.hiddenBanks, 67 | disabled: !song, 68 | }); 69 | }); 70 | 71 | core.soundManager.addEventListener("songloading", (promise, song) => { 72 | this.editor.$set({ songLoadPromise: promise }); 73 | }); 74 | 75 | core.addEventListener("beatstring", (beatString, beatIndex) => { 76 | this.editor.$set({ beatIndex: beatIndex }); 77 | }); 78 | 79 | // update any changed fields from the editor component 80 | this.editor.$on("update", (event) => { 81 | if (core.currentSong) { 82 | Object.assign(core.currentSong, event.detail); 83 | this.core.updateBeatLength(); 84 | // We may have to go backwards in time 85 | this.core.recalcBeatIndex(); 86 | 87 | this.midUpdate = true; 88 | this.core.callEventListeners("newsong", core.currentSong); 89 | this.midUpdate = false; 90 | } 91 | }); 92 | 93 | this.editor.$on("loadbuildup", (event) => 94 | this.onLoadAudio("build", event.detail) 95 | ); 96 | this.editor.$on("loadrhythm", (event) => 97 | this.onLoadAudio("loop", event.detail) 98 | ); 99 | this.editor.$on("removebuildup", (event) => this.onRemoveAudio("build")); 100 | this.editor.$on("removerhythm", (event) => this.onRemoveAudio("loop")); 101 | this.editor.$on("addbank", (event) => this.addBank()); 102 | this.editor.$on("removebank", (event) => this.removeBank(event.detail)); 103 | this.editor.$on("songnew", (event) => this.newSong()); 104 | this.editor.$on("savezip", (event) => this.saveZIP()); 105 | this.editor.$on("savexml", (event) => this.saveXML()); 106 | this.editor.$on("copyxml", (event) => this.copyXML()); 107 | } 108 | 109 | other(section: SectionName): SectionName { 110 | return { build: "loop", loop: "build" }[section] as SectionName; 111 | } 112 | 113 | async onLoadAudio(section: SectionName, sectionData: HuesSongSection) { 114 | // If first load, this makes fresh, gets the core synced up 115 | this.newSong(this.song); 116 | 117 | // brand new section may be added (eg: new build, fresh loop) 118 | this.editor.$set({ [section]: sectionData }); 119 | 120 | // Have we just added a build to a song with a rhythm, or vice versa? 121 | // If so, link their lengths 122 | let newlyLinked = 123 | !this.song![section]?.sound && !!this.song![this.other(section)]?.sound; 124 | 125 | // Do we have a loop to play? 126 | if (this.song!.loop.sound) { 127 | // Force refresh 128 | await this.core.soundManager.playSong(this.song!, true, true); 129 | if (newlyLinked) { 130 | this.setIndependentBuild(false); 131 | } 132 | this.editor.resyncEditors(); 133 | this.core.updateBeatLength(); 134 | // We may have to go backwards in time 135 | this.core.recalcBeatIndex(); 136 | } 137 | } 138 | 139 | onRemoveAudio(section: SectionName) { 140 | // Is the loop playable? 141 | if (this.song!.loop.sound) { 142 | this.core.soundManager.playSong(this.song!, true, true); 143 | } else { 144 | this.core.soundManager.stop(); 145 | } 146 | 147 | if (section == "build") { 148 | this.editor.$set({ build: undefined }); 149 | } 150 | } 151 | 152 | addBank() { 153 | if (this.song) { 154 | this.song.addBank(); 155 | // resync UI 156 | this.editor.$set({ 157 | loop: this.song.loop, 158 | build: this.song.build, 159 | hiddenBanks: this.song.hiddenBanks, 160 | }); 161 | } 162 | } 163 | 164 | removeBank(index: number) { 165 | if (this.song) { 166 | this.song.removeBank(index); 167 | // resync UI 168 | this.editor.$set({ 169 | loop: this.song.loop, 170 | build: this.song.build, 171 | hiddenBanks: this.song.hiddenBanks, 172 | }); 173 | } 174 | } 175 | 176 | newSong(song?: HuesSong) { 177 | if (!song) { 178 | song = new HuesSong("Title"); 179 | // editor-created charts are a little more vibrant 180 | song.loop.banks = ["x...o...x...o..."]; 181 | if (!this.respack) { 182 | this.respack = new Respack(); 183 | this.respack.name = "Editor Respack"; 184 | this.respack.author = "You!"; 185 | this.respack.description = 186 | "An internal resourcepack for editing new songs"; 187 | this.core.resourceManager.addPack(this.respack); 188 | } 189 | this.respack.songs.push(song); 190 | this.core.resourceManager.rebuildArrays(); 191 | this.core.resourceManager.rebuildEnabled(); 192 | this.core.setSongOject(song); 193 | } 194 | 195 | // Force independent build if only 1 source is present 196 | this.updateIndependentBuild(); 197 | 198 | // Unlock beatmap lengths 199 | this.editor.$set({ locked: false }); 200 | 201 | // You probably don't want to lose it 202 | window.onbeforeunload = () => "Unsaved beatmap - leave anyway?"; 203 | } 204 | 205 | updateIndependentBuild() { 206 | // Force independent build if only 1 source is present 207 | 208 | // Effectively `buildup ^ loop` - does only 1 exist? 209 | let hasBuild = !!this.song?.build?.sound; 210 | let hasLoop = !!this.song?.loop.sound; 211 | if (hasBuild != hasLoop) { 212 | this.setIndependentBuild(true); 213 | } 214 | } 215 | 216 | setIndependentBuild(indep: boolean) { 217 | this.editor.$set({ independentBuild: indep }); 218 | } 219 | 220 | generateXML(root?: xmlbuilder.XMLNode) { 221 | if (!this.song) { 222 | return null; 223 | } 224 | 225 | if (!root) { 226 | root = xmlbuilder.begin(); 227 | } 228 | 229 | this.song.generateXML(root); 230 | 231 | return root.end({ pretty: true }); 232 | } 233 | 234 | downloadURI(uri: string, filename: string) { 235 | // http://stackoverflow.com/a/18197341 236 | let element = document.createElement("a"); 237 | element.setAttribute("href", uri); 238 | element.setAttribute("download", filename); 239 | 240 | element.style.display = "none"; 241 | document.body.appendChild(element); 242 | 243 | element.click(); 244 | 245 | document.body.removeChild(element); 246 | } 247 | 248 | async saveZIP() { 249 | let result = this.generateXML(xmlbuilder.create("songs")); 250 | if (!result) { 251 | return; 252 | } 253 | 254 | const zipWriter = new zip.ZipWriter( 255 | new zip.Data64URIWriter("application/zip") 256 | ); 257 | await zipWriter.add("songs.xml", new zip.TextReader(result)); 258 | await this.song!.addZipAssets(zipWriter); 259 | 260 | const dataURI = await zipWriter.close(); 261 | 262 | this.downloadURI( 263 | dataURI, 264 | "0x40Hues - " + this.song!.loop.basename + ".zip" 265 | ); 266 | 267 | window.onbeforeunload = null; 268 | } 269 | 270 | saveXML() { 271 | let result = this.generateXML(xmlbuilder.create("songs")); 272 | if (!result) { 273 | return; 274 | } 275 | 276 | this.downloadURI( 277 | "data:text/plain;charset=utf-8," + encodeURIComponent(result), 278 | "0x40Hues - " + this.song!.loop.basename + ".xml" 279 | ); 280 | 281 | window.onbeforeunload = null; 282 | } 283 | 284 | // http://stackoverflow.com/a/30810322 285 | copyXML() { 286 | let text = this.generateXML(); 287 | 288 | // Clicking when disabled 289 | if (!text) { 290 | return; 291 | } 292 | 293 | let textArea = document.createElement("textarea"); 294 | textArea.className = "copybox"; 295 | 296 | textArea.value = text; 297 | 298 | document.body.appendChild(textArea); 299 | 300 | textArea.select(); 301 | 302 | let success; 303 | 304 | try { 305 | success = document.execCommand("copy"); 306 | } catch (err) { 307 | success = false; 308 | } 309 | 310 | document.body.removeChild(textArea); 311 | if (success) { 312 | this.editor.alert("Beatmap XML copied to clipboard!"); 313 | } else { 314 | this.editor.alert("Copy failed! Try saving instead"); 315 | } 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/js/HuesEditor/EditorBox.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 346 | 347 | 348 | 349 |
350 | 351 | {@html title} 352 | 353 | dispatch("rewind")}>{@html HuesIcon.REWIND} 358 | {section ? section.mapLen : 0} beats 359 | 360 | 366 | {@html locked ? HuesIcon.LOCKED : HuesIcon.UNLOCKED} 367 | 368 | 369 | copyPretty()}>{@html HuesIcon.COPY} 375 | 376 | 377 |
378 | 379 | dispatch("halve")} 381 | disabled={!section?.sound || locked || section.mapLen <= 1} 382 | > 383 | Halve 384 | 385 | dispatch("double")} 387 | disabled={!section?.sound || locked} 388 | > 389 | Double 390 | 391 | 398 | fileInput.click()}> 399 | 400 |
Load {title}
401 |
402 | Remove 405 |
406 | 407 |
408 | {#if showHelp && !section?.sound} 409 | 410 |
411 | Click [LOAD RHYTHM] to load a loop! 412 | OGG,or LAME encoded MP3s work best. 413 | 414 | You can also add a buildup with 415 | [LOAD BUILDUP], or remove it with [REMOVE]. 416 | 417 | [NEW SONG] adds a totally empty song to edit. 418 | 419 | [COPY/SAVE XML] allow for storing the rhythms 420 | and easy inclusion into a Resource Pack! 421 | 422 | Click [HELP] for advanced techniques and more 423 | information. 424 |
425 | {:else if !section?.sound} 426 |
[none]
427 | 428 | {:else if beatIndex !== null && beatIndex >= 0 && activeBanks} 429 | {#each activeBanks as _, i} 430 |
431 | {@html " ".repeat(beatIndex) + "█"} 432 |
433 | {/each} 434 | {/if} 435 | {#if section?.sound && activeBanks} 436 | {#if activeBanks.length > 1} 437 | 440 |
441 | {@html "█".repeat(section.mapLen)} 442 |
443 | {/if} 444 | 445 | {#each activeBanks as [_, bankI], i} 446 |
1 && i == activeBanks.length - 1} 449 | class:hover={bankI == bankHover} 450 | contenteditable 451 | spellcheck="false" 452 | class="beatmap" 453 | style={styleForMap(i)} 454 | on:keydown={banEnter} 455 | on:keyup={handleArrows} 456 | on:paste={handlePaste} 457 | on:input={saveLen} 458 | bind:textContent={section.banks[bankI]} 459 | on:input={handleInput} 460 | on:contextmenu={seek} 461 | on:mousedown={click} 462 | on:click={click} 463 | on:focus={() => dispatch("focus")} 464 | /> 465 | {/each} 466 | {/if} 467 |
468 | 469 | 537 | -------------------------------------------------------------------------------- /src/js/HuesEditor/InputBox.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 31 | -------------------------------------------------------------------------------- /src/js/HuesEditor/SongStats.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {label}{label ? ":" : ""} 8 | {value} 9 | {unit} 10 | 11 | 27 | -------------------------------------------------------------------------------- /src/js/HuesEditor/Timelock.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 |
22 | 23 | 29 | {@html realUnlocked ? HuesIcon.CHAIN_BROKEN : HuesIcon.CHAIN} 30 | 31 |
32 | 33 | 59 | -------------------------------------------------------------------------------- /src/js/HuesEditor/Waveform.svelte: -------------------------------------------------------------------------------- 1 | 214 | 215 | 216 | 217 | 222 | 223 | 228 | -------------------------------------------------------------------------------- /src/js/HuesIcon.ts: -------------------------------------------------------------------------------- 1 | // Generated from icomoon 2 | // prettier-ignore 3 | export enum HuesIcon { 4 | COG = "", 5 | PLAY = "", // play3 6 | PAUSE = "", // pause2 7 | SHUFFLE = "", 8 | CHAIN_BROKEN = "", // chain-broken, unlink 9 | CHAIN = "", // chain, link 10 | LOCKED = "", 11 | UNLOCKED = "", 12 | MENU = "", 13 | BACKWARD = "", // backward2 14 | FORWARD = "", // forward3 15 | REWIND = "", // first 16 | COPY = "", 17 | EYE = "", 18 | EYE_CLOSED = "", 19 | } 20 | -------------------------------------------------------------------------------- /src/js/HuesInfo.svelte: -------------------------------------------------------------------------------- 1 | 59 | 60 |
61 |
62 |

{name}

63 |
64 | {#if huesDesc} 65 |

66 | {huesDesc} 67 |

68 |
69 | {/if} 70 |

71 | Adapted from the 0x40 Flash 74 |

75 |

76 | Web-ified by mon 79 |

80 |

81 | With help from Kepstin 85 |

86 |
87 |
88 | 89 | 90 |
91 | 92 | 106 | -------------------------------------------------------------------------------- /src/js/HuesInfoList.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |

{name}

8 |
    9 | {#each items as item} 10 |
  • {item}
  • 11 | {/each} 12 |
13 |
14 | 15 | 43 | -------------------------------------------------------------------------------- /src/js/HuesSetting.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | {info.name} 12 |
13 | {#each info.options as opt} 14 | {@const inputId = uniqueFormId()} 15 | 22 | 23 | {/each} 24 | 25 |
26 |
27 | 28 | 72 | -------------------------------------------------------------------------------- /src/js/HuesSettings.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 |
44 | {#each Object.entries(settingsCategories) as [catName, cats]} 45 |
46 | {catName} 47 | {#each cats as setName} 48 | 49 | 51 | {#if setName == "autoSong" && settings["autoSong"] != "off"} 52 | after 53 | 59 | {#if settings["autoSong"] == "loop"} 60 | loop{autoPlural} 61 | {:else if settings["autoSong"] == "time"} 62 | min{autoPlural} 63 | {/if} 64 | {/if} 65 | 66 | {/each} 67 |
68 | {/each} 69 |
70 | 71 | 113 | -------------------------------------------------------------------------------- /src/js/HuesSettings.ts: -------------------------------------------------------------------------------- 1 | import "../css/hues-settings.css"; 2 | import EventListener from "./EventListener"; 3 | 4 | import SettingsUI from "./HuesSettings.svelte"; 5 | import type HuesWindow from "./HuesWindow"; 6 | 7 | /* If you're modifying settings for your hues, DON'T EDIT THIS 8 | - Go to the HTML and edit the `defaults` object instead! 9 | */ 10 | const defaultSettings: SettingsData = { 11 | // List of respacks to load 12 | respacks: [], 13 | // If true, the query string (?foo=bar&baz=boz) will be parsed for settings 14 | parseQueryString: true, 15 | // ONLY USED FOR QUERY STRINGS this will be prepended to any respacks 16 | // passed in as a ?packs=query 17 | respackPath: "respacks/", 18 | // Debugging var, for loading zips or not 19 | load: true, 20 | // Debug, play first song automatically? 21 | autoplay: true, 22 | // If true, defaults passed in initialiser override locally saved 23 | overwriteLocal: false, 24 | // If set, will attempt to play the named song first 25 | firstSong: null, 26 | // If set, will attempt to set the named image first 27 | firstImage: null, 28 | // set to false to never change images 29 | fullAuto: true, 30 | // The remote respack listing JSON endpoint 31 | // NOTE: Any packs referenced need CORS enabled or loads fail 32 | packsURL: "https://cdn.0x40hu.es/getRespacks.php", 33 | // If set, will disable the remote resources menu. For custom pages. 34 | disableRemoteResources: false, 35 | // You will rarely want to change this. Enables/disables the Hues Window. 36 | enableWindow: true, 37 | // Whether to show the Hues Window on page load 38 | showWindow: false, 39 | // What tab will be displayed first in the Hues Window 40 | firstWindow: "INFO", 41 | // Preloader customisation 42 | preloadPrefix: "0x", 43 | preloadBase: 16, 44 | preloadMax: 0x40, 45 | preloadTitle: "", 46 | // Info customisation 47 | huesName: "0x40 Hues of JS, v%VERSION%", 48 | huesDesc: `0x40 Hues has some music and a few images, and the 49 | music plays and the images change. 50 | This is such a fine idea, like wowzers. 51 | Som- many like it!`, 52 | // If unset, uses , otherwise sets which element to turn hues-y 53 | root: null, 54 | // If set, keyboard shortcuts are ignored 55 | disableKeyboard: false, 56 | 57 | // UI accessible config 58 | smartAlign: "on", 59 | blurAmount: "medium", 60 | blurDecay: "fast", 61 | blurQuality: "medium", 62 | currentUI: "modern", 63 | colourSet: "normal", 64 | blendMode: "hard-light", 65 | bgColour: "transparent", 66 | blackoutUI: "off", 67 | invertStyle: "everything", 68 | playBuildups: "on", 69 | visualiser: "off", 70 | shuffleImages: "on", 71 | autoSong: "off", 72 | autoSongDelay: 5, // loops or minutes depending on autoSong value 73 | autoSongShuffle: "on", 74 | autoSongFadeout: "on", 75 | trippyMode: "off", 76 | volume: 0.7, 77 | skipPreloader: "off", 78 | }; 79 | 80 | // for the UI accessible config only 81 | const settingsOptions = { 82 | smartAlign: { 83 | name: "Smart Align images", 84 | options: ["off", "on"], 85 | }, 86 | blurAmount: { 87 | name: "Blur amount", 88 | options: ["low", "medium", "high"], 89 | }, 90 | blurDecay: { 91 | name: "Blur decay", 92 | options: ["slow", "medium", "fast", "faster!"], 93 | }, 94 | blurQuality: { 95 | name: "Blur quality", 96 | options: ["low", "medium", "high", "extreme"], 97 | }, 98 | visualiser: { 99 | name: "Spectrum analyser", 100 | options: ["off", "on"], 101 | }, 102 | currentUI: { 103 | name: "UI style", 104 | options: ["retro", "v4.20", "modern", "xmas", "hlwn", "mini"], 105 | }, 106 | colourSet: { 107 | name: "Colour set", 108 | options: ["normal", "pastel", "v4.20"], 109 | }, 110 | blendMode: { 111 | name: "Blend mode", 112 | options: ["hard-light", "screen", "multiply"], 113 | }, 114 | bgColour: { 115 | name: "Render backdrop", 116 | options: ["white", "black", "transparent"], 117 | }, 118 | blackoutUI: { 119 | name: "Blackout affects UI", 120 | options: ["off", "on"], 121 | }, 122 | invertStyle: { 123 | name: "Invert affects", 124 | options: ["everything", "image"], 125 | }, 126 | playBuildups: { 127 | name: "Play buildups", 128 | options: ["off", "once", "on"], 129 | }, 130 | autoSong: { 131 | name: "AutoSong", 132 | options: ["off", "loop", "time"], 133 | }, 134 | autoSongShuffle: { 135 | name: "AutoSong shuffle", 136 | options: ["off", "on"], 137 | }, 138 | autoSongFadeout: { 139 | name: "AutoSong fade out", 140 | options: ["off", "on"], 141 | }, 142 | trippyMode: { 143 | name: "Trippy Mode", 144 | options: ["off", "on"], 145 | }, 146 | shuffleImages: { 147 | name: "Shuffle images", 148 | options: ["off", "on"], 149 | }, 150 | skipPreloader: { 151 | name: "Skip preloader warning", 152 | options: ["off", "on"], 153 | }, 154 | } as const; // this magic little thing lets us use "options" as a tuple type! 155 | 156 | export type SettingsData = { 157 | respacks: string[]; 158 | parseQueryString: boolean; 159 | respackPath: string; 160 | load: boolean; 161 | autoplay: boolean; 162 | overwriteLocal: boolean; 163 | firstSong: string | null; 164 | firstImage: string | null; 165 | fullAuto: boolean; 166 | packsURL: string; 167 | disableRemoteResources: boolean; 168 | enableWindow: boolean; 169 | showWindow: boolean; 170 | firstWindow: string; 171 | preloadPrefix: string; 172 | preloadBase: number; 173 | preloadMax: number; 174 | preloadTitle: string; 175 | huesName: string; 176 | huesDesc: string; 177 | root: HTMLElement | string | null; 178 | disableKeyboard: boolean; 179 | 180 | // UI accessible config 181 | smartAlign: (typeof settingsOptions.smartAlign.options)[number]; 182 | blurAmount: (typeof settingsOptions.blurAmount.options)[number]; 183 | blurDecay: (typeof settingsOptions.blurDecay.options)[number]; 184 | blurQuality: (typeof settingsOptions.blurQuality.options)[number]; 185 | currentUI: (typeof settingsOptions.currentUI.options)[number]; 186 | colourSet: (typeof settingsOptions.colourSet.options)[number]; 187 | blendMode: (typeof settingsOptions.blendMode.options)[number]; 188 | bgColour: (typeof settingsOptions.bgColour.options)[number]; 189 | blackoutUI: (typeof settingsOptions.blackoutUI.options)[number]; 190 | invertStyle: (typeof settingsOptions.invertStyle.options)[number]; 191 | playBuildups: (typeof settingsOptions.playBuildups.options)[number]; 192 | visualiser: (typeof settingsOptions.visualiser.options)[number]; 193 | shuffleImages: (typeof settingsOptions.shuffleImages.options)[number]; 194 | autoSong: (typeof settingsOptions.autoSong.options)[number]; 195 | autoSongShuffle: (typeof settingsOptions.autoSongShuffle.options)[number]; 196 | autoSongFadeout: (typeof settingsOptions.autoSongFadeout.options)[number]; 197 | trippyMode: (typeof settingsOptions.trippyMode.options)[number]; 198 | skipPreloader: (typeof settingsOptions.skipPreloader.options)[number]; 199 | autoSongDelay: number; 200 | volume: number; 201 | }; 202 | 203 | type SettingsEvents = { 204 | // Called when settings are updated 205 | updated: () => void; 206 | }; 207 | 208 | export interface HuesSettings extends SettingsData {} 209 | export class HuesSettings extends EventListener { 210 | ephemerals: Partial; 211 | ui?: SettingsUI; 212 | 213 | constructor(defaults: Partial) { 214 | super(); 215 | 216 | let settingsVersion = "1"; 217 | if (localStorage.settingsVersion != settingsVersion) { 218 | localStorage.clear(); 219 | localStorage.settingsVersion = settingsVersion; 220 | } 221 | 222 | this.ephemerals = {}; 223 | 224 | for (let _attr in defaultSettings) { 225 | let attr = _attr as keyof SettingsData; 226 | Object.defineProperty(this, attr, { 227 | set: this.makeSetter(attr as keyof SettingsData), 228 | get: this.makeGetter(attr as keyof SettingsData), 229 | }); 230 | 231 | // this is too tricky for typescript and/or my brain, so just be 232 | // lazy with the 233 | if (defaults[attr] !== undefined) { 234 | if (defaults.overwriteLocal) { 235 | (this)[attr] = defaults[attr]; 236 | } else { 237 | (this.ephemerals)[attr] = defaults[attr]; 238 | } 239 | } 240 | } 241 | 242 | if (this.parseQueryString) { 243 | let querySettings = this.getQuerySettings(); 244 | 245 | for (let _attr in defaultSettings) { 246 | let attr = _attr as keyof SettingsData; 247 | // query string overrides, finally 248 | if (querySettings[attr] !== undefined && attr != "respacks") { 249 | (this.ephemerals)[attr] = querySettings[attr]; 250 | } 251 | } 252 | 253 | this.respacks = this.respacks.concat(querySettings.respacks!); 254 | } 255 | } 256 | 257 | getQuerySettings() { 258 | let results: Partial = {}; 259 | results.respacks = []; 260 | let query = window.location.search.substring(1); 261 | let vars = query.split("&"); 262 | for (let i = 0; i < vars.length; i++) { 263 | let pair = vars[i].split("="); 264 | let val: string | boolean = decodeURIComponent(pair[1]); 265 | if (pair[0] == "packs" || pair[0] == "respacks") { 266 | let packs = val.split(","); 267 | for (let j = 0; j < packs.length; j++) { 268 | results.respacks.push(this.respackPath + packs[j]); 269 | } 270 | } else if (pair[0] == "song") { 271 | // alias for firstSong 272 | results.firstSong = val; 273 | } else { 274 | // since we can set ephemeral variables this way 275 | if (val === "true" || val === "false") val = val == "true"; 276 | (results)[pair[0]] = val; 277 | } 278 | } 279 | return results; 280 | } 281 | 282 | initUI(huesWin: HuesWindow) { 283 | let uiTab = huesWin.addTab("OPTIONS"); 284 | this.ui = new SettingsUI({ 285 | target: uiTab, 286 | props: { 287 | settings: this, 288 | schema: settingsOptions, 289 | }, 290 | }); 291 | 292 | this.ui.$on("update", () => { 293 | this.callEventListeners("updated"); 294 | }); 295 | } 296 | 297 | makeGetter(setting: keyof SettingsData) { 298 | return () => { 299 | if (defaultSettings.hasOwnProperty(setting)) { 300 | if (this.ephemerals[setting] !== undefined) 301 | return this.ephemerals[setting]; 302 | else if (localStorage[setting] !== undefined) 303 | return localStorage[setting]; 304 | else return defaultSettings[setting]; 305 | } else { 306 | console.log("WARNING: Attempted to fetch invalid setting:", setting); 307 | return null; 308 | } 309 | }; 310 | } 311 | 312 | // Set a named index to its named value, returns false if name doesn't exist 313 | makeSetter(setting: S) { 314 | return (value: SettingsData[S]) => { 315 | if (this.isEphemeral(setting)) { 316 | this.ephemerals[setting] = value; 317 | } else { 318 | let opt = (settingsOptions)[setting]; 319 | if (!opt || opt.options.indexOf(value) == -1) { 320 | console.error(value, "is not a valid value for", setting); 321 | return false; 322 | } 323 | localStorage[setting] = value; 324 | this.ephemerals[setting] = undefined; 325 | } 326 | 327 | if (this.ui) { 328 | this.ui.$set({ settings: this }); 329 | } 330 | return true; 331 | }; 332 | } 333 | 334 | isEphemeral(setting: keyof SettingsData) { 335 | return !settingsOptions.hasOwnProperty(setting); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/js/HuesWindow.ts: -------------------------------------------------------------------------------- 1 | import EventListener from "./EventListener"; 2 | import type { HuesSettings } from "./HuesSettings"; 3 | 4 | type WindowEvents = { 5 | // When the window is shown, hidden or toggled this fires. 6 | // 'shown' is true if the window was made visible, false otherwise 7 | windowshown: (shown: boolean) => void; 8 | //The name of the tab that was selected 9 | tabselected: (tabName: string) => void; 10 | }; 11 | 12 | export default class HuesWindow extends EventListener { 13 | hasUI: boolean; 14 | 15 | window: Element; 16 | tabContainer: Element; 17 | contentContainer: Element; 18 | contents: Element[]; 19 | tabs: Element[]; 20 | tabNames: string[]; 21 | tabSelected?: string; 22 | 23 | constructor(root: Element, settings: HuesSettings) { 24 | super(); 25 | 26 | this.hasUI = settings.enableWindow; 27 | 28 | this.window = document.createElement("div"); 29 | this.window.className = "hues-win-helper"; 30 | root.appendChild(this.window); 31 | 32 | let actualWindow = document.createElement("div"); 33 | actualWindow.className = "hues-win"; 34 | this.window.appendChild(actualWindow); 35 | 36 | let closeButton = document.createElement("div"); 37 | closeButton.className = "hues-win__closebtn"; 38 | closeButton.onclick = this.hide.bind(this); 39 | actualWindow.appendChild(closeButton); 40 | 41 | this.tabContainer = document.createElement("div"); 42 | this.tabContainer.className = "hues-win__tabs"; 43 | actualWindow.appendChild(this.tabContainer); 44 | 45 | this.contentContainer = document.createElement("div"); 46 | this.contentContainer.className = "hues-win__content"; 47 | actualWindow.appendChild(this.contentContainer); 48 | 49 | this.contents = []; 50 | this.tabs = []; 51 | this.tabNames = []; 52 | 53 | if (settings.showWindow) { 54 | this.show(); 55 | } else { 56 | this.hide(); 57 | } 58 | } 59 | 60 | addTab(tabName: string, tabContent?: Element) { 61 | let label = document.createElement("div"); 62 | label.textContent = tabName; 63 | label.className = "tab-label"; 64 | label.onclick = () => this.selectTab(tabName); 65 | this.tabContainer.appendChild(label); 66 | this.tabs.push(label); 67 | this.tabNames.push(tabName); 68 | 69 | let content = document.createElement("div"); 70 | content.className = "tab-content"; 71 | if (tabContent) { 72 | content.appendChild(tabContent); 73 | } 74 | this.contentContainer.appendChild(content); 75 | this.contents.push(content); 76 | 77 | // for the slow Svelte migration - use this as the `target` in `new Component()` 78 | return content; 79 | } 80 | 81 | selectTab(tabName: string, dontShowWin?: boolean) { 82 | if (!this.hasUI) return; 83 | if (!dontShowWin) { 84 | this.show(); 85 | } 86 | for (let i = 0; i < this.tabNames.length; i++) { 87 | let name = this.tabNames[i]; 88 | if (tabName.toLowerCase() == name.toLowerCase()) { 89 | this.contents[i].classList.add("tab-content--active"); 90 | this.tabs[i].classList.add("tab-label--active"); 91 | this.tabSelected = name; 92 | this.callEventListeners("tabselected", name); 93 | } else { 94 | this.contents[i].classList.remove("tab-content--active"); 95 | this.tabs[i].classList.remove("tab-label--active"); 96 | } 97 | } 98 | } 99 | 100 | // If the window isn't shown, show it. If the tab isn't selected, select it. 101 | // If the window is shown AND the tab is selected, hide the window 102 | selectOrToggle(tabName: string) { 103 | if (!this.hasUI) return; 104 | 105 | if (tabName.toLowerCase() == this.tabSelected?.toLowerCase()) { 106 | this.toggle(); 107 | } else { 108 | this.show(); 109 | this.selectTab(tabName); 110 | } 111 | } 112 | 113 | hide() { 114 | this.window.classList.add("hidden"); 115 | this.callEventListeners("windowshown", false); 116 | } 117 | 118 | show() { 119 | if (!this.hasUI) return; 120 | 121 | this.window.classList.remove("hidden"); 122 | this.callEventListeners("windowshown", true); 123 | } 124 | 125 | toggle() { 126 | if (!this.hasUI) return; 127 | if (this.hidden) { 128 | this.show(); 129 | } else { 130 | this.hide(); 131 | } 132 | } 133 | 134 | get hidden() { 135 | return this.window.classList.contains("hidden"); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/js/RespackEditor/App.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 |

HE HAS NO STYLE, HE HAS NO GRACE. THIS RESPACK EDITOR HAS A FUNNY FACE.

35 | 36 |
37 | 38 | or... 39 | 45 |
46 | 47 | {#if pack} 48 |
49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 | 66 | 67 | 68 |
Songs: {pack.songs.length}
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | {#each pack.songs as song} 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 98 | 99 | {/each} 100 |
TitleSourceBanksLoop lenLoop fileBuild lenBuild filecharsPerBeatindependentBuild
{song.bankCount}{song.loop.mapLen}{song.loop.filename}{song.build ? song.build.mapLen : "n/a"}{song.build ? song.build.filename : "n/a"}{song.charsPerBeat} 92 | {#if song.build} 93 | 94 | {:else} 95 | n/a 96 | {/if} 97 |
101 | 102 |
Images: {pack.images.length}
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | {#each pack.images as image} 115 | 116 | 117 | 118 | 119 | 126 | 127 | 134 | 141 | 148 | 149 | {/each} 150 |
NameFull nameSourceAlignImg #frameDurationsbeatsPerAnimsyncOffset
120 | 125 | {image.bitmaps.length} 128 | {#if image.animated} 129 | 130 | {:else} 131 | n/a 132 | {/if} 133 | 135 | {#if image.animated} 136 | 137 | {:else} 138 | n/a 139 | {/if} 140 | 142 | {#if image.animated} 143 | 144 | {:else} 145 | n/a 146 | {/if} 147 |
151 |
152 | {:else} 153 | Load a pack, ya dingus 154 | {/if} 155 | 156 | 166 | -------------------------------------------------------------------------------- /src/js/RespackEditor/ImageEdit.svelte: -------------------------------------------------------------------------------- 1 | 121 | 122 | 127 | 128 | {#if selectedImage} 129 |
130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 142 | 143 | 144 | 150 | {#if selectedImage.centerPixel !== undefined} 151 | 157 | 164 | {/if} 165 |
166 |
167 | 168 | 169 | 170 | 171 | 180 | 188 | 189 |
190 | 191 | 196 | 197 | 198 | 199 | 204 | 205 | 206 | 211 | 212 | 213 | 221 | 222 | {#if canvas} 223 | 224 | 229 | {/if} 230 |
231 | 232 | 233 |
234 |
235 | {/if} 236 | 237 | 259 | -------------------------------------------------------------------------------- /src/js/RespackEditor/main.ts: -------------------------------------------------------------------------------- 1 | import App from "./App.svelte"; 2 | 3 | // not strictly required but it makes webpack put it in `dist` 4 | import "../../../respack_edit.html"; 5 | 6 | const app = new App({ target: document.body }); 7 | 8 | export default app; 9 | 10 | // for the index to mess with stuff if it wants to 11 | (window as any).respackEditor = app; 12 | -------------------------------------------------------------------------------- /src/js/SoundManager.ts: -------------------------------------------------------------------------------- 1 | import type { HuesCore } from "./HuesCore"; 2 | import type { HuesSong, HuesSongSection } from "./ResourcePack"; 3 | import EventListener from "./EventListener"; 4 | import CodecParser, { type CodecValue } from "codec-parser"; 5 | import { type MPEGDecodedAudio, MPEGDecoderWebWorker } from "mpg123-decoder"; 6 | import { 7 | type OggOpusDecodedAudio, 8 | OggOpusDecoderWebWorker, 9 | } from "ogg-opus-decoder"; 10 | import { 11 | type OggVorbisDecodedAudio, 12 | OggVorbisDecoderWebWorker, 13 | } from "@wasm-audio-decoders/ogg-vorbis"; 14 | 15 | type SoundCallbacks = { 16 | // Called when the audio has been seeked - reset time determined transforms 17 | seek: () => void; 18 | 19 | // Called when someone requests a new song to be played - used when 20 | // you want to do something with the finished AudioBuffers, like 21 | // display length, or display a waveform. 22 | songloading: (promise: Promise, song: HuesSong) => void; 23 | }; 24 | 25 | type SongBuffer = { 26 | source?: AudioBufferSourceNode; 27 | buffer?: AudioBuffer; 28 | length: number; // For calculating beat lengths 29 | }; 30 | 31 | // just for songLoad 32 | type AudioBuffers = { loop?: AudioBuffer; buildup?: AudioBuffer }; 33 | 34 | declare global { 35 | // the most logical place to store these 36 | interface AudioBuffer { 37 | replayGain?: number; 38 | } 39 | } 40 | 41 | interface SoundManagerSong extends HuesSong { 42 | _loadPromise?: Promise; 43 | } 44 | 45 | export default class SoundManager extends EventListener { 46 | core: HuesCore; 47 | playing: boolean; 48 | playbackRate: number; 49 | song?: HuesSong; 50 | 51 | initPromise?: Promise; 52 | lockedPromise?: Promise; 53 | locked: boolean; 54 | 55 | // Lower level audio and timing info 56 | // @ts-ignore: Object is possibly 'undefined'. 57 | context!: AudioContext; // Audio context, Web Audio API 58 | build: SongBuffer; 59 | loop: SongBuffer; 60 | 61 | startTime: number; // File start time - 0 is loop start, not build start 62 | 63 | // Volume 64 | gainNode!: GainNode; 65 | replayGainNode!: GainNode; 66 | mute: boolean; 67 | lastVol: number; 68 | 69 | // Visualiser 70 | vReady: boolean; 71 | vBars: number; 72 | vTotalBars: number; 73 | splitter?: ChannelSplitterNode; 74 | analysers: AnalyserNode[]; 75 | analyserArrays: Uint8Array[]; 76 | logArrays: Uint8Array[]; 77 | binCutoffs: number[]; 78 | linBins: number; 79 | logBins: number; 80 | maxBinLin: number; 81 | 82 | constructor(core: HuesCore, initialVolume = 1) { 83 | // Perhaps this will do more later 84 | super(); 85 | 86 | this.core = core; 87 | this.playing = false; 88 | this.playbackRate = 1; 89 | 90 | this.locked = true; 91 | 92 | this.build = { length: 0 }; 93 | this.loop = { length: 0 }; 94 | this.startTime = 0; 95 | 96 | this.mute = false; 97 | this.lastVol = initialVolume; 98 | 99 | this.vReady = false; 100 | this.vBars = 0; 101 | this.vTotalBars = 0; 102 | this.analysers = []; 103 | this.analyserArrays = []; 104 | this.logArrays = []; 105 | this.binCutoffs = []; 106 | this.linBins = 0; 107 | this.logBins = 0; 108 | this.maxBinLin = 0; 109 | } 110 | 111 | init() { 112 | if (!this.initPromise) { 113 | this.initPromise = this._init(); 114 | } 115 | return this.initPromise; 116 | } 117 | 118 | private async _init() { 119 | // Check Web Audio API Support 120 | try { 121 | this.context = new AudioContext(); 122 | this.gainNode = this.context.createGain(); 123 | this.replayGainNode = this.context.createGain(); 124 | this.gainNode.connect(this.context.destination); 125 | this.replayGainNode.connect(this.gainNode); 126 | } catch (e) { 127 | throw Error("Web Audio API not supported in this browser."); 128 | } 129 | 130 | // ensure decoders work 131 | await new MPEGDecoderWebWorker().ready; 132 | await new OggOpusDecoderWebWorker().ready; 133 | await new OggVorbisDecoderWebWorker().ready; 134 | 135 | this.locked = this.context.state != "running"; 136 | } 137 | 138 | unlock() { 139 | if (this.lockedPromise) { 140 | return this.lockedPromise; 141 | } 142 | this.lockedPromise = new Promise((resolve, reject) => { 143 | // iOS and other some mobile browsers - unlock the context as 144 | // it starts in a suspended state 145 | let unlocker = () => { 146 | // create empty buffer 147 | let buffer = this.context.createBuffer(1, 1, 22050); 148 | let source = this.context.createBufferSource(); 149 | source.buffer = buffer; 150 | 151 | // connect to output (your speakers) 152 | source.connect(this.context.destination); 153 | 154 | // play the file 155 | source.start(0); 156 | 157 | window.removeEventListener("touchend", unlocker); 158 | window.removeEventListener("click", unlocker); 159 | this.core.clearMessage(); 160 | resolve(); 161 | }; 162 | window.addEventListener("touchend", unlocker, false); 163 | window.addEventListener("click", unlocker, false); 164 | }); 165 | return this.lockedPromise; 166 | } 167 | 168 | playSong( 169 | song: HuesSong, 170 | playBuild: boolean, 171 | forcePlay: boolean = false 172 | ): Promise { 173 | let promise = this._playSong(song, playBuild, forcePlay); 174 | this.callEventListeners("songloading", promise, song); 175 | return promise; 176 | } 177 | 178 | private async _playSong( 179 | song: HuesSong, 180 | playBuild: boolean, 181 | forcePlay: boolean 182 | ) { 183 | // Editor forces play on audio updates 184 | if (this.song == song && !forcePlay) { 185 | return; 186 | } 187 | this.stop(); 188 | this.song = song; 189 | if (!song || !song.loop.sound) { 190 | // null song 191 | return; 192 | } 193 | 194 | // if there's a fadeout happening from AutoSong, kill it 195 | this.gainNode.gain.cancelScheduledValues(0); 196 | // Reset original volume 197 | this.setVolume(this.lastVol); 198 | if (this.mute) { 199 | this.setMute(true); 200 | } 201 | 202 | let buffers = await this.loadSong(song); 203 | // To prevent race condition if you press "next" twice fast 204 | if (song != this.song) { 205 | throw Error( 206 | "Song changed between load and play - this message can be ignored" 207 | ); 208 | } 209 | 210 | this.build.buffer = buffers.buildup; 211 | this.build.length = this.build.buffer ? this.build.buffer.duration : 0; 212 | this.loop.buffer = buffers.loop; 213 | this.loop.length = this.loop.buffer!.duration; 214 | 215 | // This fixes sync issues on Firefox and slow machines. 216 | await this.context.suspend(); 217 | 218 | if (playBuild) { 219 | this.seek(-this.build.length, true); 220 | } else { 221 | this.seek(0, true); 222 | } 223 | 224 | await this.context.resume(); 225 | 226 | this.playing = true; 227 | } 228 | 229 | stop(dontDeleteBuffers?: boolean) { 230 | if (this.playing) { 231 | if (this.build.source) { 232 | this.build.source.stop(0); 233 | this.build.source.disconnect(); 234 | this.build.source = undefined; 235 | if (!dontDeleteBuffers) this.build.buffer = undefined; 236 | } 237 | // arg required for mobile webkit 238 | this.loop.source!.stop(0); 239 | // TODO needed? 240 | this.loop.source!.disconnect(); 241 | this.loop.source = undefined; 242 | if (!dontDeleteBuffers) this.loop.buffer = undefined; 243 | this.vReady = false; 244 | this.playing = false; 245 | this.startTime = 0; 246 | } 247 | } 248 | 249 | setRate(rate: number) { 250 | // Double speed is more than enough. Famous last words? 251 | rate = Math.max(Math.min(rate, 2), 0.25); 252 | 253 | let time = this.clampedTime; 254 | this.playbackRate = rate; 255 | this.seek(time); 256 | } 257 | 258 | seek(time: number, noPlayingUpdate: boolean = false) { 259 | if (!this.song) { 260 | return; 261 | } 262 | 263 | this.callEventListeners("seek"); 264 | 265 | //console.log("Seeking to " + time); 266 | // Clamp the blighter 267 | time = Math.min(Math.max(time, -this.build.length), this.loop.length); 268 | 269 | this.stop(true); 270 | 271 | if (!this.loop.buffer) { 272 | return; 273 | } 274 | 275 | this.loop.source = this.context.createBufferSource(); 276 | this.loop.source.buffer = this.loop.buffer; 277 | this.loop.source.playbackRate.value = this.playbackRate; 278 | this.loop.source.loop = true; 279 | this.loop.source.loopStart = 0; 280 | this.loop.source.loopEnd = this.loop.length; 281 | this.loop.source.connect(this.replayGainNode!); 282 | 283 | if (time < 0 && this.build.buffer) { 284 | this.build.source = this.context.createBufferSource(); 285 | this.build.source.buffer = this.build.buffer; 286 | this.build.source.playbackRate.value = this.playbackRate; 287 | this.build.source.connect(this.replayGainNode!); 288 | this.build.source.start(0, this.build.length + time); 289 | this.loop.source.start( 290 | this.context.currentTime - time / this.playbackRate 291 | ); 292 | } else { 293 | this.loop.source.start(0, time); 294 | } 295 | 296 | let gain = this.loop.buffer.replayGain; 297 | if (this.build.buffer) { 298 | gain = Math.min(gain!, this.build.buffer.replayGain!); 299 | } 300 | this.replayGainNode.gain.setValueAtTime(gain!, this.context.currentTime); 301 | 302 | this.startTime = this.context.currentTime - time / this.playbackRate; 303 | if (!noPlayingUpdate) { 304 | this.playing = true; 305 | } 306 | this.initVisualiser(); 307 | this.core.recalcBeatIndex(); 308 | } 309 | 310 | // In seconds, relative to the loop start 311 | get currentTime() { 312 | if (!this.playing) { 313 | return 0; 314 | } 315 | return (this.context.currentTime - this.startTime) * this.playbackRate; 316 | } 317 | 318 | get clampedTime() { 319 | let time = this.currentTime; 320 | 321 | if (time > 0) { 322 | time %= this.loop.length; 323 | } 324 | return time; 325 | } 326 | 327 | loadSong(song: SoundManagerSong): Promise { 328 | if (song._loadPromise) { 329 | /* Caused when moving back/forwards rapidly. 330 | The sound is still loading. We reject this promise, and the already 331 | running decode will finish and resolve instead. 332 | NOTE: If anything but playSong calls loadSong, this idea is broken. */ 333 | return Promise.reject( 334 | "Song changed between load and play - this message can be ignored" 335 | ); 336 | } 337 | 338 | let buffers: AudioBuffers = {}; 339 | 340 | let promises = [ 341 | this.loadBuffer(song.loop).then((buffer) => { 342 | buffers.loop = buffer; 343 | }), 344 | ]; 345 | if (song.build?.sound) { 346 | promises.push( 347 | this.loadBuffer(song.build).then((buffer) => { 348 | buffers.buildup = buffer; 349 | }) 350 | ); 351 | } else { 352 | this.build.length = 0; 353 | } 354 | song._loadPromise = Promise.all(promises).then(() => { 355 | song._loadPromise = undefined; 356 | return buffers; 357 | }); 358 | return song._loadPromise; 359 | } 360 | 361 | async loadBuffer(section: HuesSongSection): Promise { 362 | let buffer = section.sound; 363 | 364 | if (!buffer) { 365 | throw Error("Section has no buffer: " + section); 366 | } 367 | 368 | let decoded: OggOpusDecodedAudio | OggVorbisDecodedAudio | MPEGDecodedAudio; 369 | 370 | // Is this a file supported by the browser's importer? 371 | let view = new Uint8Array(buffer); 372 | if ( 373 | // Signature for ogg file: OggS 374 | view[0] == 0x4f && 375 | view[1] == 0x67 && 376 | view[2] == 0x67 && 377 | view[3] == 0x53 378 | ) { 379 | let codec: CodecValue | undefined = undefined; 380 | const parser = new CodecParser("audio/ogg", { 381 | onCodec: (c) => { 382 | codec = c; 383 | }, 384 | enableFrameCRC32: false, 385 | }); 386 | parser.parseChunk(view).next(); 387 | if (codec === "opus") { 388 | const decoder = new OggOpusDecoderWebWorker({ forceStereo: true }); 389 | await decoder.ready; 390 | decoded = await decoder.decodeFile(view); 391 | } else if (codec === "vorbis") { 392 | const decoder = new OggVorbisDecoderWebWorker(); 393 | await decoder.ready; 394 | decoded = await decoder.decodeFile(view); 395 | } else if (codec === undefined) { 396 | throw Error("Cannot determine OGG codec"); 397 | } else { 398 | throw Error(`Unsupported OGG codec ${codec}`); 399 | } 400 | } else if ( 401 | // untagged MP3 402 | (view[0] == 0xff && 403 | (view[1] == 0xfb || 404 | view[1] == 0xfa || 405 | view[1] == 0xf2 || 406 | view[1] == 0xf3)) || 407 | // ID3v2 tagged MP3 "ID3" 408 | (view[0] == 0x49 && view[1] == 0x44 && view[2] == 0x33) 409 | ) { 410 | const decoder = new MPEGDecoderWebWorker({ enableGapless: true }); 411 | await decoder.ready; 412 | decoded = await decoder.decode(view); 413 | } else { 414 | throw Error("Cannot determine filetype"); 415 | } 416 | 417 | let audio = this.audioBufFromRaw(decoded); 418 | this.applyGain(audio); 419 | return audio; 420 | } 421 | 422 | // Converts continuous PCM array to Web Audio API friendly format 423 | audioBufFromRaw( 424 | raw: OggOpusDecodedAudio | OggVorbisDecodedAudio | MPEGDecodedAudio 425 | ): AudioBuffer { 426 | let audioBuf = this.context.createBuffer( 427 | raw.channelData.length, 428 | raw.samplesDecoded, 429 | raw.sampleRate 430 | ); 431 | for (const [i, channel] of raw.channelData.entries()) { 432 | // Most browsers 433 | if (typeof audioBuf.copyToChannel === "function") { 434 | audioBuf.copyToChannel(channel, i, 0); 435 | } else { 436 | // Safari, Edge sometimes 437 | audioBuf.getChannelData(i).set(channel); 438 | } 439 | } 440 | return audioBuf; 441 | } 442 | 443 | // find rough ReplayGain volume to normalise song audio 444 | // from https://github.com/est31/js-audio-normalizer 445 | applyGain(data: AudioBuffer) { 446 | let buffer = data.getChannelData(0); 447 | var sliceLen = Math.floor(data.sampleRate * 0.05); 448 | var averages = []; 449 | var sum = 0.0; 450 | for (var i = 0; i < buffer.length; i++) { 451 | sum += Math.pow(buffer[i], 2); 452 | if (i % sliceLen === 0) { 453 | sum = Math.sqrt(sum / sliceLen); 454 | averages.push(sum); 455 | sum = 0; 456 | } 457 | } 458 | // Ascending sort of the averages array 459 | averages.sort(function (a, b) { 460 | return a - b; 461 | }); 462 | // Take the average at the 95th percentile 463 | var a = averages[Math.floor(averages.length * 0.95)]; 464 | 465 | var gain = 1.0 / a; 466 | // Perform some clamping 467 | gain = Math.max(gain, 0.02); 468 | gain = Math.min(gain, 100.0); 469 | 470 | // ReplayGain uses pink noise for this one one but we just take 471 | // some arbitrary value... we're no standard 472 | // Important is only that we don't output on levels 473 | // too different from other websites 474 | data.replayGain = gain / 4.0; 475 | } 476 | 477 | initVisualiser(bars?: number) { 478 | // When restarting the visualiser 479 | if (!bars) { 480 | bars = this.vTotalBars; 481 | } 482 | this.vReady = false; 483 | this.vTotalBars = bars; 484 | for (let i = 0; i < this.analysers.length; i++) { 485 | this.analysers[i].disconnect(); 486 | } 487 | if (this.splitter) { 488 | this.splitter.disconnect(); 489 | this.splitter = undefined; 490 | } 491 | this.analysers = []; 492 | this.analyserArrays = []; 493 | this.logArrays = []; 494 | this.binCutoffs = []; 495 | 496 | this.linBins = 0; 497 | this.logBins = 0; 498 | this.maxBinLin = 0; 499 | 500 | this.attachVisualiser(); 501 | } 502 | 503 | attachVisualiser() { 504 | if (!this.playing || this.vReady) { 505 | return; 506 | } 507 | 508 | // Get our info from the loop 509 | let channels = this.loop.source!.channelCount; 510 | // In case channel counts change, this is changed each time 511 | this.splitter = this.context.createChannelSplitter(channels); 512 | // Connect to the gainNode so we get buildup stuff too 513 | this.loop.source!.connect(this.splitter); 514 | if (this.build.source) { 515 | this.build.source.connect(this.splitter); 516 | } 517 | // Split display up into each channel 518 | this.vBars = Math.floor(this.vTotalBars / channels); 519 | 520 | for (let i = 0; i < channels; i++) { 521 | let analyser = this.context.createAnalyser(); 522 | // big fft buffers are new-ish 523 | try { 524 | analyser.fftSize = 8192; 525 | } catch (err) { 526 | analyser.fftSize = 2048; 527 | } 528 | // Chosen because they look nice, no maths behind it 529 | analyser.smoothingTimeConstant = 0.6; 530 | analyser.minDecibels = -70; 531 | analyser.maxDecibels = -25; 532 | this.analyserArrays.push(new Uint8Array(analyser.frequencyBinCount)); 533 | analyser.getByteTimeDomainData(this.analyserArrays[i]); 534 | this.splitter.connect(analyser, i); 535 | this.analysers.push(analyser); 536 | this.logArrays.push(new Uint8Array(this.vBars)); 537 | } 538 | let binCount = this.analysers[0].frequencyBinCount; 539 | let binWidth = this.loop.source!.buffer!.sampleRate / binCount; 540 | // first 2kHz are linear 541 | this.maxBinLin = Math.floor(2000 / binWidth); 542 | // Don't stretch the first 2kHz, it looks awful 543 | this.linBins = Math.min(this.maxBinLin, Math.floor(this.vBars / 2)); 544 | // Only go up to 22KHz 545 | let maxBinLog = Math.floor(22000 / binWidth); 546 | let logBins = this.vBars - this.linBins; 547 | 548 | let logLow = Math.log2(2000); 549 | let logDiff = Math.log2(22000) - logLow; 550 | for (let i = 0; i < logBins; i++) { 551 | let cutoff = i * (logDiff / logBins) + logLow; 552 | let freqCutoff = Math.pow(2, cutoff); 553 | let binCutoff = Math.floor(freqCutoff / binWidth); 554 | this.binCutoffs.push(binCutoff); 555 | } 556 | this.vReady = true; 557 | } 558 | 559 | sumArray(array: Uint8Array, low: number, high: number) { 560 | let total = 0; 561 | for (let i = low; i <= high; i++) { 562 | total += array[i]; 563 | } 564 | return total / (high - low + 1); 565 | } 566 | 567 | getVisualiserData() { 568 | if (!this.vReady) { 569 | return null; 570 | } 571 | for (let a = 0; a < this.analyserArrays.length; a++) { 572 | let data = this.analyserArrays[a]; 573 | let result = this.logArrays[a]; 574 | this.analysers[a].getByteFrequencyData(data); 575 | 576 | for (let i = 0; i < this.linBins; i++) { 577 | let scaled = Math.round((i * this.maxBinLin) / this.linBins); 578 | result[i] = data[scaled]; 579 | } 580 | result[this.linBins] = data[this.binCutoffs[0]]; 581 | for (let i = this.linBins + 1; i < this.vBars; i++) { 582 | let cutoff = i - this.linBins; 583 | result[i] = this.sumArray( 584 | data, 585 | this.binCutoffs[cutoff - 1], 586 | this.binCutoffs[cutoff] 587 | ); 588 | } 589 | } 590 | return this.logArrays; 591 | } 592 | 593 | setMute(mute: boolean) { 594 | if (!this.mute && mute) { 595 | // muting 596 | this.lastVol = this.gainNode.gain.value; 597 | } 598 | let newVol; 599 | if (mute) { 600 | newVol = 0; 601 | } else { 602 | newVol = this.lastVol; 603 | } 604 | this.gainNode.gain.setValueAtTime(newVol, this.context.currentTime); 605 | this.core.userInterface?.updateVolume(newVol); 606 | this.mute = mute; 607 | return mute; 608 | } 609 | 610 | toggleMute() { 611 | return this.setMute(!this.mute); 612 | } 613 | 614 | decreaseVolume() { 615 | this.setMute(false); 616 | let val = Math.max(this.gainNode.gain.value - 0.1, 0); 617 | this.setVolume(val); 618 | } 619 | 620 | increaseVolume() { 621 | this.setMute(false); 622 | let val = Math.min(this.gainNode.gain.value + 0.1, 1); 623 | this.setVolume(val); 624 | } 625 | 626 | setVolume(vol: number) { 627 | this.gainNode.gain.setValueAtTime(vol, this.context.currentTime); 628 | this.lastVol = vol; 629 | this.core.userInterface?.updateVolume(vol); 630 | } 631 | 632 | fadeOut(callback: () => void) { 633 | if (!this.mute) { 634 | // Firefox hackery 635 | this.gainNode.gain.setValueAtTime(this.lastVol, this.context.currentTime); 636 | this.gainNode.gain.exponentialRampToValueAtTime( 637 | 0.01, 638 | this.context.currentTime + 2 639 | ); 640 | } 641 | setTimeout(callback, 2000); 642 | } 643 | } 644 | -------------------------------------------------------------------------------- /src/js/UniqueID.ts: -------------------------------------------------------------------------------- 1 | let i = 0; 2 | 3 | export default function uniqueFormId() { 4 | i++; 5 | return "hues-input-" + i; 6 | } 7 | -------------------------------------------------------------------------------- /src/js/Utils.ts: -------------------------------------------------------------------------------- 1 | // percent 0.0 = oldColour, percent 1.0 = newColour 2 | export function mixColours( 3 | oldColour: number, 4 | newColour: number, 5 | percent: number 6 | ) { 7 | percent = Math.min(1, percent); 8 | let oldR = (oldColour >> 16) & 0xff; 9 | let oldG = (oldColour >> 8) & 0xff; 10 | let oldB = oldColour & 0xff; 11 | let newR = (newColour >> 16) & 0xff; 12 | let newG = (newColour >> 8) & 0xff; 13 | let newB = newColour & 0xff; 14 | let mixR = oldR * (1 - percent) + newR * percent; 15 | let mixG = oldG * (1 - percent) + newG * percent; 16 | let mixB = oldB * (1 - percent) + newB * percent; 17 | return (mixR << 16) | (mixG << 8) | mixB; 18 | } 19 | 20 | export function intToHex(num: number, pad = 6) { 21 | return "#" + num.toString(16).padStart(pad, "0"); 22 | } 23 | -------------------------------------------------------------------------------- /src/js/audio/audio-worker.js: -------------------------------------------------------------------------------- 1 | var window = self; 2 | importScripts('../audio-min.js'); 3 | 4 | var deinterleave = function(buffer, asset) { 5 | var channels = asset.format.channelsPerFrame, 6 | len = buffer.length / channels; 7 | 8 | var result = new Float32Array(len * channels); 9 | 10 | for(var sample = 0; sample < len; sample++) { 11 | for(var channel = 0; channel < channels; channel++) { 12 | result[channel*len + sample] = buffer[(sample)*channels + channel]; 13 | } 14 | } 15 | return result; 16 | } 17 | 18 | self.addEventListener('message', function(e) { 19 | if(!e.data.ogg) { 20 | importScripts('../ogg.js', '../vorbis.js', '../opus.js'); 21 | } 22 | if(!e.data.mp3) { 23 | importScripts('../mpg123.js'); 24 | } 25 | 26 | // To see if things are working, we can ping the worker 27 | if(e.data.ping) { 28 | self.postMessage({ping: true}); 29 | return; 30 | } 31 | 32 | var arrayBuffer = e.data.buffer; 33 | 34 | var asset = AV.Asset.fromBuffer(arrayBuffer); 35 | 36 | // On error we still want to restore the audio file 37 | asset.on("error", function(error) { 38 | self.postMessage({arrayBuffer : arrayBuffer, 39 | error: String(error)}, 40 | [arrayBuffer]); 41 | }); 42 | 43 | asset.decodeToBuffer(function(buffer) { 44 | var fixedBuffer = deinterleave(buffer, asset); 45 | var raw = {array: fixedBuffer, 46 | sampleRate: asset.format.sampleRate, 47 | channels: asset.format.channelsPerFrame} 48 | self.postMessage({rawAudio : raw, 49 | arrayBuffer : arrayBuffer}, 50 | // transfer objects to save a copy 51 | [fixedBuffer.buffer, arrayBuffer]); 52 | }); 53 | 54 | }, false); 55 | -------------------------------------------------------------------------------- /src/js/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const VERSION: string; 2 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | // without this file, vscode language server can't deal with ts svelte files 2 | const sveltePreprocess = require('svelte-preprocess'); 3 | 4 | module.exports = { 5 | preprocess: sveltePreprocess() 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "target": "es2020", 6 | "sourceMap": true, 7 | "strict": true, 8 | // Don't clobber the src dir if accidentally running tsc 9 | "outDir": "dist_tsc" 10 | }, 11 | "include": ["./src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const process = require('process'); 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 5 | const SveltePreprocess = require('svelte-preprocess'); 6 | const SvelteCheckPlugin = require('svelte-check-plugin'); 7 | const { EsbuildPlugin } = require('esbuild-loader'); 8 | 9 | const isDevServer = process.env.WEBPACK_SERVE; 10 | 11 | let optimization; 12 | let svelteCheck; 13 | if(isDevServer) { 14 | optimization = { 15 | minimize: false 16 | }; 17 | svelteCheck = []; 18 | } else { 19 | optimization = { 20 | minimizer: [ 21 | new EsbuildPlugin({ 22 | target: 'es2020', // Syntax to compile to (see options below for possible values) 23 | css: true // Apply minification to CSS assets 24 | }), 25 | ], 26 | }; 27 | svelteCheck = [new SvelteCheckPlugin()]; 28 | } 29 | 30 | const commonSettings = { 31 | mode: 'production', 32 | devtool: 'source-map', 33 | performance: { 34 | hints: false, 35 | }, 36 | resolve: { 37 | extensions: ['.tsx', '.ts', '.js'], 38 | }, 39 | optimization: optimization, 40 | }; 41 | 42 | const commonRules = [ 43 | { 44 | test: /\.svelte$/, 45 | use: { 46 | loader: 'svelte-loader', 47 | options: { 48 | preprocess: SveltePreprocess() 49 | } 50 | } 51 | }, 52 | { 53 | test: /\.tsx?$/, 54 | loader: "esbuild-loader", 55 | exclude: /node_modules/, 56 | options: { 57 | loader: 'ts', 58 | target: 'es2020' 59 | } 60 | }, 61 | { 62 | test: /.s?css$/, 63 | use: [MiniCssExtractPlugin.loader, "css-loader"],//, "sass-loader"], 64 | }, 65 | { 66 | test: /\.(png|jpe?g|gif|eot|svg|ttf|woff|ico|html)$/i, 67 | type: 'asset/resource', 68 | }, 69 | ]; 70 | 71 | const commonPlugins = [ 72 | new webpack.DefinePlugin({ 73 | VERSION: JSON.stringify(require("./package.json").version) 74 | }), 75 | ...svelteCheck 76 | ]; 77 | 78 | module.exports = [ 79 | { 80 | ...commonSettings, 81 | entry: './src/js/HuesCore.ts', 82 | output: { 83 | filename: 'lib/hues-min.js', 84 | path: path.resolve(__dirname, 'dist'), 85 | assetModuleFilename: '[path][name][ext][query]' 86 | }, 87 | 88 | module: { 89 | rules: commonRules, 90 | }, 91 | 92 | plugins: [new MiniCssExtractPlugin({ 93 | filename: 'css/hues-min.css', 94 | }), ...commonPlugins], 95 | }, 96 | { 97 | ...commonSettings, 98 | entry: './src/js/RespackEditor/main.ts', 99 | output: { 100 | filename: 'lib/respack-editor-min.js', 101 | path: path.resolve(__dirname, 'dist'), 102 | assetModuleFilename: '[path][name][ext][query]' 103 | }, 104 | 105 | module: { 106 | rules: commonRules, 107 | }, 108 | 109 | plugins: [ 110 | new MiniCssExtractPlugin({ 111 | filename: 'css/respack-editor-min.css', 112 | }), 113 | ...commonPlugins 114 | ], 115 | }, 116 | ]; 117 | --------------------------------------------------------------------------------