├── src
├── js
│ ├── global.d.ts
│ ├── UniqueID.ts
│ ├── RespackEditor
│ │ ├── main.ts
│ │ ├── App.svelte
│ │ └── ImageEdit.svelte
│ ├── HuesUICSS.ts
│ ├── HuesEditor
│ │ ├── SongStats.svelte
│ │ ├── InputBox.svelte
│ │ ├── Timelock.svelte
│ │ ├── Waveform.svelte
│ │ ├── EditorBox.svelte
│ │ └── Main.svelte
│ ├── HuesIcon.ts
│ ├── Utils.ts
│ ├── HuesInfoList.svelte
│ ├── EventListener.ts
│ ├── HuesSetting.svelte
│ ├── Components
│ │ └── HuesButton.svelte
│ ├── HuesInfo.svelte
│ ├── HuesSettingsUI.svelte
│ ├── HuesWindow.ts
│ ├── HuesEditor.svelte.ts
│ ├── HuesSettings.svelte.ts
│ └── HuesCanvas2D.ts
└── css
│ ├── hues-settings.css
│ ├── huesUI-weed.css
│ ├── huesUI-retro.css
│ ├── huesUI-xmas.css
│ ├── hues-respacks.css
│ ├── hues-main.css
│ ├── huesUI-hlwn.css
│ └── huesUI-modern.css
├── .gitignore
├── .gitconfig
├── img
├── bones.png
├── lighton.png
├── skull.png
├── left-hand.png
├── lightbase.png
├── lightoff.png
├── tombstone.png
├── vignette.png
├── wiresleft.png
├── right-hand.png
├── skull-eyes.png
├── web-topleft.png
├── web-topright.png
├── wiresbottom.png
├── wiresright.png
├── lightoff_inverted.png
├── lighton_inverted.png
├── tombstone_invert.png
└── web-bottomright.png
├── fonts
├── HuesExtra.woff
└── PetMe64.woff
├── public
└── favicon.ico
├── docs
├── img
│ ├── hues_420.png
│ ├── hues_hlwn.png
│ ├── hues_xmas.png
│ ├── hues_cowbell.png
│ ├── hues_default.png
│ ├── hues_snoop.png
│ └── hues_montegral.png
├── MP3 Export.md
├── Editor.md
└── Respacks.md
├── .git-blame-ignore-revs
├── .vscode
├── extensions.json
└── settings.json
├── .prettierrc.json
├── tsconfig.json
├── .jshintrc
├── svelte.config.js
├── tsconfig.app.json
├── tsconfig.node.json
├── .github
└── workflows
│ └── vite.yml
├── respack_edit.html
├── vite.config.ts
├── index.html
├── LICENSE
├── package.json
└── README.md
/src/js/global.d.ts:
--------------------------------------------------------------------------------
1 | declare const VERSION: string;
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | respacks/
2 | node_modules/
3 | dist/
4 |
5 |
--------------------------------------------------------------------------------
/.gitconfig:
--------------------------------------------------------------------------------
1 | [blame]
2 | ignoreRevsFile = .git-blame-ignore-revs
3 |
--------------------------------------------------------------------------------
/img/bones.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/bones.png
--------------------------------------------------------------------------------
/img/lighton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/lighton.png
--------------------------------------------------------------------------------
/img/skull.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/skull.png
--------------------------------------------------------------------------------
/img/left-hand.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/left-hand.png
--------------------------------------------------------------------------------
/img/lightbase.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/lightbase.png
--------------------------------------------------------------------------------
/img/lightoff.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/lightoff.png
--------------------------------------------------------------------------------
/img/tombstone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/tombstone.png
--------------------------------------------------------------------------------
/img/vignette.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/vignette.png
--------------------------------------------------------------------------------
/img/wiresleft.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/wiresleft.png
--------------------------------------------------------------------------------
/fonts/HuesExtra.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/fonts/HuesExtra.woff
--------------------------------------------------------------------------------
/fonts/PetMe64.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/fonts/PetMe64.woff
--------------------------------------------------------------------------------
/img/right-hand.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/right-hand.png
--------------------------------------------------------------------------------
/img/skull-eyes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/skull-eyes.png
--------------------------------------------------------------------------------
/img/web-topleft.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/web-topleft.png
--------------------------------------------------------------------------------
/img/web-topright.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/web-topright.png
--------------------------------------------------------------------------------
/img/wiresbottom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/wiresbottom.png
--------------------------------------------------------------------------------
/img/wiresright.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/wiresright.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/docs/img/hues_420.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_420.png
--------------------------------------------------------------------------------
/docs/img/hues_hlwn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_hlwn.png
--------------------------------------------------------------------------------
/docs/img/hues_xmas.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_xmas.png
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Global run of Prettier
2 | 6ff7c7afe7f087c2ed79335d9dba693f021abb21
3 |
--------------------------------------------------------------------------------
/docs/img/hues_cowbell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_cowbell.png
--------------------------------------------------------------------------------
/docs/img/hues_default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_default.png
--------------------------------------------------------------------------------
/docs/img/hues_snoop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_snoop.png
--------------------------------------------------------------------------------
/img/lightoff_inverted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/lightoff_inverted.png
--------------------------------------------------------------------------------
/img/lighton_inverted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/lighton_inverted.png
--------------------------------------------------------------------------------
/img/tombstone_invert.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/tombstone_invert.png
--------------------------------------------------------------------------------
/img/web-bottomright.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/img/web-bottomright.png
--------------------------------------------------------------------------------
/docs/img/hues_montegral.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mon/0x40-web/HEAD/docs/img/hues_montegral.png
--------------------------------------------------------------------------------
/src/js/UniqueID.ts:
--------------------------------------------------------------------------------
1 | let i = 0;
2 |
3 | export default function uniqueFormId() {
4 | i++;
5 | return "hues-input-" + i;
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "esbenp.prettier-vscode",
4 | "svelte.svelte-vscode",
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-svelte"],
3 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.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/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 |
--------------------------------------------------------------------------------
/src/js/RespackEditor/main.ts:
--------------------------------------------------------------------------------
1 | import App from "./App.svelte";
2 | import { mount } from "svelte";
3 |
4 | const app = mount(App, { target: document.body, props: { pack: undefined } });
5 |
6 | export default app;
7 |
8 | // for the index to mess with stuff if it wants to
9 | (window as any).respackEditor = app;
10 |
--------------------------------------------------------------------------------
/src/js/HuesUICSS.ts:
--------------------------------------------------------------------------------
1 | // main, modern and hlwn must be kept in that order
2 | import "../css/hues-main.css";
3 | import "../css/huesUI-modern.css";
4 | import "../css/huesUI-hlwn.css";
5 | // the rest can be any order
6 | import "../css/huesUI-retro.css";
7 | import "../css/huesUI-weed.css";
8 | import "../css/huesUI-xmas.css";
9 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
2 |
3 | export default {
4 | preprocess: vitePreprocess(),
5 | // note: keep up to date with the one in webpack.config.js
6 | compilerOptions: {
7 | // disable all accessibility warnings
8 | warningFilter: (warning) => !warning.code.startsWith('a11y')
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/src/js/HuesEditor/SongStats.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 | {label}{label ? ":" : ""}
8 | {value}
9 | {unit}
10 |
11 |
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "resolveJsonModule": true,
8 | /**
9 | * Typecheck JS in `.svelte` and `.js` files by default.
10 | * Disable checkJs if you'd like to use dynamic types in JS.
11 | * Note that setting allowJs false does not prevent the use
12 | * of JS in `.svelte` files.
13 | */
14 | "allowJs": true,
15 | "checkJs": true,
16 | "isolatedModules": true,
17 | "moduleDetection": "force"
18 | },
19 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/vite.yml:
--------------------------------------------------------------------------------
1 | name: Vite
2 |
3 | on:
4 | push:
5 | branches: ["**"]
6 | pull_request:
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | strategy:
13 | matrix:
14 | node-version: [22.x]
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 |
24 | - name: Build
25 | run: |
26 | npm install
27 | npm run build
28 |
29 | - name: Save artifacts
30 | uses: actions/upload-artifact@v4
31 | with:
32 | name: hues
33 | path: dist/**/*
34 |
--------------------------------------------------------------------------------
/respack_edit.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 0x40 Respack Editor
7 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/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/HuesInfoList.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
{name}
8 |
9 | {#each items as item}
10 | {item}
11 | {/each}
12 |
13 |
14 |
15 |
43 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { resolve } from "path";
3 | import serveStatic from "serve-static";
4 | import { svelte } from "@sveltejs/vite-plugin-svelte";
5 |
6 | export default defineConfig({
7 | build: {
8 | rollupOptions: {
9 | input: {
10 | index: resolve(__dirname, "index.html"),
11 | respack_edit: resolve(__dirname, "respack_edit.html"),
12 | },
13 | },
14 | sourcemap: true,
15 | },
16 | define: {
17 | VERSION: JSON.stringify(require("./package.json").version),
18 | },
19 | plugins: [
20 | svelte(),
21 | // Put all testing/development respacks in dev_public
22 | {
23 | name: "dev-public",
24 | configureServer(server) {
25 | server.middlewares.use(serveStatic(resolve(__dirname, "dev_public")));
26 | },
27 | },
28 | ],
29 | });
30 |
--------------------------------------------------------------------------------
/src/js/HuesEditor/InputBox.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 | {@html label}
22 |
23 |
24 |
40 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 0x40
7 |
21 |
22 |
23 |
24 |
25 | This page requires Javascript.
26 |
27 |
28 |
--------------------------------------------------------------------------------
/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 :)
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "0x40-web",
3 | "version": "5.4",
4 | "description": "Pretty images and colours",
5 | "type": "module",
6 | "directories": {
7 | "doc": "docs"
8 | },
9 | "scripts": {
10 | "dev": "vite",
11 | "build": "vite build",
12 | "preview": "vite preview",
13 | "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
14 | "format": "prettier --write ./**/*.svelte ./**/*.css ./**/*.ts"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/mon/0x40-web.git"
19 | },
20 | "author": "mon",
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/mon/0x40-web/issues"
24 | },
25 | "homepage": "https://github.com/mon/0x40-web#readme",
26 | "devDependencies": {
27 | "@sveltejs/vite-plugin-svelte": "^6.1.3",
28 | "@tsconfig/svelte": "^5.0.4",
29 | "@types/serve-static": "^1.15.7",
30 | "@types/string_score": "^0.1.28",
31 | "prettier": "^3.5.3",
32 | "prettier-plugin-svelte": "^3.3.3",
33 | "serve-static": "^2.2.0",
34 | "svelte": "^5.28.2",
35 | "svelte-check": "^4.1.6",
36 | "typescript": "^5.8.3",
37 | "vite": "^7.1.3"
38 | },
39 | "dependencies": {
40 | "@wasm-audio-decoders/ogg-vorbis": "^0.1.16",
41 | "@zip.js/zip.js": "^2.7.60",
42 | "codec-parser": "^2.5.0",
43 | "mpg123-decoder": "^1.0.0",
44 | "ogg-opus-decoder": "^1.6.15",
45 | "string_score": "^0.1.22",
46 | "xmlbuilder": "^15.1.1"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/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/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/js/HuesSetting.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 | {info.name}
18 |
19 | {#each info.options as opt}
20 | {@const inputId = uniqueFormId()}
21 |
28 | {opt}
29 | {/each}
30 | {@render children?.()}
31 |
32 |
33 |
34 |
78 |
--------------------------------------------------------------------------------
/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:
41 | visibility 0.5s linear,
42 | opacity 0.5s linear;
43 | }
44 |
45 | .hues-w-controls.hues-ui--hidden,
46 | .hues-w-subcontrols.hues-ui--hidden,
47 | .hues-w-beatbar.hues-ui--hidden {
48 | visibility: hidden;
49 | opacity: 0;
50 | }
51 |
52 | .hues-w-beatleft,
53 | .hues-w-beatright {
54 | font-size: 13px;
55 | position: absolute;
56 | padding: 0 0 0 5px;
57 | top: 5px;
58 | overflow: hidden;
59 | border-radius: 0 10px 10px 0;
60 | white-space: nowrap;
61 | }
62 | .hues-w-beatleft {
63 | transform: scaleX(-1);
64 | left: 8px;
65 | right: 50%;
66 | }
67 | .hues-w-beatright {
68 | left: 50%;
69 | right: 8px;
70 | }
71 |
72 | .hues-w-beataccent {
73 | position: absolute;
74 | left: 0;
75 | right: 0;
76 | margin-left: auto;
77 | margin-right: auto;
78 | margin-top: 15px;
79 | text-align: center;
80 | font-size: 35px;
81 | opacity: 0;
82 | /* from grey to not so grey */
83 | text-shadow: hsl(0, 0%, calc(40% + var(--invert) * 20%));
84 | text-shadow: -2px 2px 0 #666;
85 |
86 | animation-name: fallspin;
87 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
88 | animation-duration: 0.5s;
89 | }
90 |
91 | @keyframes fallspin {
92 | from {
93 | transform: rotate(0deg) translate(0, 0);
94 | opacity: 1;
95 | }
96 | }
97 |
98 | .hues-r-visualisercontainer.hues-w-visualisercontainer {
99 | top: 17px;
100 | }
101 |
--------------------------------------------------------------------------------
/src/js/Components/HuesButton.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
27 |
28 |
29 |
30 |
108 |
--------------------------------------------------------------------------------
/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:
106 | visibility 0.5s linear,
107 | opacity 0.5s linear;
108 | }
109 |
110 | .hues-r-container.hues-ui--hidden,
111 | .hues-r-controls.hues-ui--hidden,
112 | .hues-r-subcontrols.hues-ui--hidden {
113 | visibility: hidden;
114 | opacity: 0;
115 | }
116 |
117 | .hues-r-listcontainer {
118 | position: absolute;
119 | right: 35px;
120 | bottom: 45px;
121 | }
122 |
123 | .hues-r-visualisercontainer {
124 | transform: scaleY(-1);
125 | position: absolute;
126 | width: 100%;
127 | height: 64px;
128 | top: 0;
129 | left: 0;
130 | }
131 |
132 | @media (max-width: 700px) {
133 | .hues-r-listcontainer {
134 | left: 130px;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/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/HuesSettingsUI.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 | {#each Object.entries(settingsCategories) as [catName, cats]}
46 |
47 | {catName}
48 | {#each cats as setName}
49 |
50 |
52 | {#if setName == "autoSong" && settings.autoSong != "off"}
53 | after
54 |
60 | {#if settings.autoSong == "loop"}
61 | loop{autoPlural}
62 | {:else if settings.autoSong == "time"}
63 | min{autoPlural}
64 | {/if}
65 | {/if}
66 |
67 | {/each}
68 |
69 | {/each}
70 |
71 |
72 |
114 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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?inline");
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?inline");
54 | opacity: calc(1 - var(--invert));
55 | }
56 | .hues-x-lighton.inverted {
57 | background-image: url("../../img/lighton_inverted.png?inline");
58 | opacity: var(--invert);
59 | }
60 |
61 | .hues-x-lightoff {
62 | opacity: 0;
63 | background-image: url("../../img/lightoff.png?inline");
64 | }
65 | .hues-x-lightoff.inverted {
66 | background-image: url("../../img/lightoff_inverted.png?inline");
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?inline");
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?inline");
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?inline");
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/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/js/HuesWindow.ts:
--------------------------------------------------------------------------------
1 | import EventListener from "./EventListener";
2 | import type { SettingsData } from "./HuesSettings.svelte";
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: SettingsData) {
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/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?inline") format("woff");
8 | }
9 |
10 | @font-face {
11 | font-family: "icomoon";
12 | src: url("../../fonts/HuesExtra.woff?inline") format("woff");
13 | font-weight: normal;
14 | font-style: normal;
15 | }
16 |
17 | .hues-root {
18 | height: 100%;
19 | margin: 0;
20 | padding: 0;
21 | overflow: hidden;
22 | font-family: "PetMe64Web";
23 | position: relative;
24 | background-color: transparent;
25 | }
26 |
27 | .hues-root h1,
28 | .hues-root h2,
29 | .hues-root h3 {
30 | text-align: center;
31 | }
32 |
33 | .hues-root h1 {
34 | font-size: 15pt;
35 | }
36 |
37 | .hues-root h2 {
38 | font-size: 10pt;
39 | }
40 |
41 | .hues-root h3 {
42 | font-size: 7pt;
43 | }
44 |
45 | .hidden {
46 | display: none !important;
47 | }
48 |
49 | .invisible {
50 | visibility: hidden !important;
51 | }
52 |
53 | .hues-icon {
54 | /* use !important to prevent issues with browser extensions that change fonts */
55 | font-family: "icomoon" !important;
56 | speak: none;
57 | font-style: normal;
58 | font-weight: normal;
59 | font-variant: normal;
60 | text-transform: none;
61 | line-height: 1;
62 |
63 | /* Better Font Rendering =========== */
64 | -webkit-font-smoothing: antialiased;
65 | -moz-osx-font-smoothing: grayscale;
66 | }
67 |
68 | .hues-canvas {
69 | position: absolute;
70 | top: 0;
71 | left: 0;
72 | display: block;
73 | height: 100%;
74 | padding: 0;
75 | z-index: -10;
76 | background-color: white;
77 | }
78 |
79 | .hues-visualiser {
80 | position: absolute;
81 | z-index: -1;
82 | }
83 |
84 | .hues-preloader {
85 | /* first 2 colours are the loaded colour, next 2 are unloaded */
86 | background: linear-gradient(to right, #fff 0%, #fff 50%, #ddd 50%, #ddd 100%);
87 | background-size: 200% 100%;
88 | background-position: 100% 0;
89 |
90 | width: 100%;
91 | height: 100%;
92 | display: flex;
93 | justify-content: center;
94 | align-items: center;
95 | flex-direction: column;
96 | font-size: 25pt;
97 |
98 | position: absolute;
99 | top: 0;
100 | left: 0;
101 | z-index: 10;
102 | visibility: visible;
103 | opacity: 1;
104 | transition:
105 | visibility 1s linear,
106 | opacity 1s linear,
107 | background-position 0.5s ease;
108 | }
109 |
110 | .hues-preloader--loaded {
111 | visibility: hidden;
112 | opacity: 0;
113 | }
114 |
115 | .hues-preloader__title {
116 | /* Just ballpark it and hope the given title isn't super long */
117 | font-size: min(30pt, calc(100vw / 15));
118 | }
119 |
120 | .hues-preloader__text {
121 | /* "Initialising..." is 15 chars long, clamp to the viewport width */
122 | font-size: min(25pt, calc(100vw / 15));
123 | display: block;
124 | text-align: center;
125 | }
126 |
127 | .hues-preloader__subtext {
128 | /* "Tap or click to start" is 21 chars long, clamp to the viewport width */
129 | font-size: min(12pt, calc(100vw / 21));
130 | text-align: center;
131 | }
132 |
133 | .hues-preloader__subtext span {
134 | /* 8pt is sufficiently small to not worry about clamping */
135 | font-size: 8pt;
136 | opacity: 0.7;
137 | }
138 |
139 | .hues-ui {
140 | /* from 0.0 to 1.0 */
141 | --invert: 0;
142 | }
143 |
144 | .unstyled-link {
145 | color: inherit;
146 | text-decoration: none;
147 | }
148 |
149 | .hues-button {
150 | font-size: 10px;
151 | margin: 3px 2px;
152 | padding: 3px;
153 | background-color: rgba(127, 127, 127, 0.5);
154 | border-color: rgb(0, 0, 0);
155 | border-width: 1px;
156 | border-style: solid;
157 | cursor: pointer;
158 | /* Don't want double click to select */
159 | -webkit-touch-callout: none;
160 | -webkit-user-select: none;
161 | -khtml-user-select: none;
162 | -moz-user-select: none;
163 | -ms-user-select: none;
164 | user-select: none;
165 | }
166 |
167 | .hues-button--loaded {
168 | background-color: rgba(0, 127, 0, 0.5);
169 | cursor: default;
170 | }
171 |
172 | .hues-button--disabled {
173 | color: #777;
174 | cursor: default;
175 | }
176 |
177 | .hues-button:hover {
178 | background: rgba(255, 255, 255, 0.5);
179 | }
180 |
181 | .hues-button--loaded:hover {
182 | background-color: rgba(0, 127, 0, 0.5);
183 | cursor: default;
184 | }
185 |
186 | .hues-button--disabled:hover {
187 | background-color: rgba(127, 127, 127, 0.5);
188 | }
189 |
190 | .hues-button--glow {
191 | animation-name: glow;
192 | animation-duration: 2s;
193 | animation-iteration-count: infinite;
194 | }
195 |
196 | @keyframes glow {
197 | from {
198 | background-color: rgba(127, 127, 127, 0.5);
199 | }
200 | 50% {
201 | background-color: rgba(0, 127, 0, 0.5);
202 | }
203 | to {
204 | background-color: rgba(127, 127, 127, 0.5);
205 | }
206 | }
207 |
--------------------------------------------------------------------------------
/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 | ](https://0x40.mon.im/)
11 | [420 Hues
12 | ](https://420.mon.im/)
13 | [Halloween Hues
14 | ](https://spook.mon.im/)
15 | [Christmas Hues
16 | ](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 | ](https://420.mon.im/snoop.html)
21 | ["Montegral"
22 | ](https://0x40.mon.im/montegral.html)
23 | [More Cowbell
24 | ](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 `npm run build`. It will create a `dist` folder. For seamless
102 | development with auto-reload, `npm run dev` - 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.svelte.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 |
--------------------------------------------------------------------------------
/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 | Brand new pack
38 | or...
39 |
45 |
46 |
47 | {#if pack}
48 |
160 | {:else}
161 | Load a pack, ya dingus
162 | {/if}
163 |
164 |
174 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/js/HuesEditor/Waveform.svelte:
--------------------------------------------------------------------------------
1 |
214 |
215 |
216 |
217 |
222 |
223 |
228 |
--------------------------------------------------------------------------------
/src/js/RespackEditor/ImageEdit.svelte:
--------------------------------------------------------------------------------
1 |
119 |
120 |
121 | {#each images as image}
122 | {image.name}
123 | {/each}
124 |
125 |
126 | {#if selectedImage}
127 |
128 | Name:
129 |
130 |
131 | Full name:
132 |
133 |
134 | Align:
135 |
136 | center
137 | left
138 | right
139 |
140 |
141 | Center pixel
142 |
148 | {#if selectedImage.centerPixel !== undefined}
149 |
155 |
162 | {/if}
163 |
164 |
238 | {/if}
239 |
240 |
262 |
--------------------------------------------------------------------------------
/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?inline");
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?inline");
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?inline");
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?inline");
106 | left: -15px;
107 | }
108 |
109 | .hues-h-right-hand {
110 | background: url("../../img/right-hand.png?inline");
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?inline) 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?inline) 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?inline) 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?inline) 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?inline");
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?inline");
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?inline");
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?inline");
336 | background-size: 100% 100%;
337 | width: 100%;
338 | height: 100%;
339 | position: absolute;
340 | z-index: -1;
341 | }
342 |
--------------------------------------------------------------------------------
/src/js/HuesEditor.svelte.ts:
--------------------------------------------------------------------------------
1 | import type xmlbuilder from "xmlbuilder";
2 |
3 | import { HuesSong, Respack, type HuesSongSection } from "./ResourcePack.svelte";
4 | import EditorMain from "./HuesEditor/Main.svelte";
5 | import type { HuesCore } from "./HuesCore.svelte";
6 | import type HuesWindow from "./HuesWindow";
7 | import type EditorBoxSvelte from "./HuesEditor/EditorBox.svelte";
8 | import { mount, type ComponentProps } from "svelte";
9 |
10 | const _xmlbuilder = import("xmlbuilder");
11 | const _zip = import("@zip.js/zip.js");
12 |
13 | export interface EditorUndoRedo {
14 | builds?: string[];
15 | loops?: string[];
16 | independentBuild: boolean;
17 | caret?: number;
18 | editor?: EditorBoxSvelte;
19 | }
20 |
21 | type SectionName = "build" | "loop";
22 |
23 | export class HuesEditor {
24 | core: HuesCore;
25 | editorProps!: ComponentProps;
26 | editor!: ReturnType;
27 |
28 | // for storing respacks created with "new"
29 | respack?: Respack;
30 |
31 | // to avoid recursion
32 | midUpdate!: boolean;
33 |
34 | constructor(core: HuesCore, huesWin: HuesWindow) {
35 | this.core = core;
36 | if (!core.settings.enableWindow) {
37 | return;
38 | }
39 |
40 | this.midUpdate = false;
41 |
42 | let container = huesWin.addTab("EDITOR");
43 | const editorProps = $state({
44 | huesRoot: this.core.root,
45 | soundManager: this.core.soundManager,
46 | // if the first window is the editor, the user doesn't want the extra click
47 | // but eh, maybe the performance impact really isn't that bad
48 | totallyDisabled: false,
49 | // totallyDisabled: this.core.settings.firstWindow != 'EDITOR',
50 | });
51 | this.editorProps = editorProps;
52 | this.editor = mount(EditorMain, {
53 | target: container,
54 | props: this.editorProps,
55 | events: {
56 | loadbuildup: (event) => this.onLoadAudio("build", event.detail),
57 | loadrhythm: (event) => this.onLoadAudio("loop", event.detail),
58 | removebuildup: (event) => this.onRemoveAudio("build"),
59 | removerhythm: (event) => this.onRemoveAudio("loop"),
60 | songnew: (event) => this.newSong(),
61 | savezip: (event) => this.saveZIP(),
62 | savexml: (event) => this.saveXML(),
63 | copyxml: (event) => this.copyXML(),
64 | // update any changed fields from the editor component
65 | update: (event) => {
66 | if (core.currentSong) {
67 | this.core.updateBeatLength();
68 | // We may have to go backwards in time
69 | this.core.recalcBeatIndex();
70 |
71 | this.midUpdate = true;
72 | this.core.callEventListeners("newsong", core.currentSong);
73 | this.midUpdate = false;
74 | }
75 | },
76 | },
77 | });
78 |
79 | core.addEventListener("newsong", (song) => {
80 | if (this.midUpdate) {
81 | return;
82 | }
83 |
84 | this.editorProps.song = song;
85 | });
86 |
87 | core.soundManager.addEventListener("songloading", (promise, song) => {
88 | this.editorProps.songLoadPromise = promise;
89 | });
90 |
91 | core.addEventListener("beatstring", (beatString, beatIndex) => {
92 | this.editorProps.beatIndex = beatIndex;
93 | });
94 | }
95 |
96 | other(section: SectionName): SectionName {
97 | return { build: "loop", loop: "build" }[section] as SectionName;
98 | }
99 |
100 | async onLoadAudio(section: SectionName, sectionData: HuesSongSection) {
101 | // If first load, this makes fresh, gets the core synced up
102 | this.newSong(this.editorProps.song);
103 |
104 | // brand new section may be added (eg: new build, fresh loop)
105 | this.editorProps.song![section] = sectionData;
106 |
107 | // Have we just added a build to a song with a rhythm, or vice versa?
108 | // If so, link their lengths
109 | let newlyLinked =
110 | !this.editorProps.song![section]?.sound &&
111 | !!this.editorProps.song![this.other(section)]?.sound;
112 |
113 | // Do we have a loop to play?
114 | if (this.editorProps.song!.loop.sound) {
115 | // Force refresh
116 | await this.core.soundManager.playSong(this.editorProps.song!, true, true);
117 | if (newlyLinked) {
118 | this.setIndependentBuild(false);
119 | }
120 | this.editor.resyncEditors();
121 | this.core.updateBeatLength();
122 | // We may have to go backwards in time
123 | this.core.recalcBeatIndex();
124 | }
125 | }
126 |
127 | onRemoveAudio(section: SectionName) {
128 | // Is the loop playable?
129 | if (this.editorProps.song!.loop.sound) {
130 | this.core.soundManager.playSong(this.editorProps.song!, true, true);
131 | } else {
132 | this.core.soundManager.stop();
133 | }
134 |
135 | if (section == "build") {
136 | this.editorProps.song!.build = undefined;
137 | }
138 | }
139 |
140 | newSong(song?: HuesSong) {
141 | if (!song) {
142 | song = new HuesSong("Title");
143 | // editor-created charts are a little more vibrant
144 | song.loop.banks = ["x...o...x...o..."];
145 | if (!this.respack) {
146 | this.respack = new Respack();
147 | this.respack.name = "Editor Respack";
148 | this.respack.author = "You!";
149 | this.respack.description =
150 | "An internal resourcepack for editing new songs";
151 | this.core.resourceManager.addPack(this.respack);
152 | }
153 | this.respack.songs.push(song);
154 | this.core.resourceManager.rebuildArrays();
155 | this.core.resourceManager.rebuildEnabled();
156 | this.core.setSongOject(song);
157 | this.editorProps.songLoadPromise = undefined;
158 | }
159 |
160 | // Force independent build if only 1 source is present
161 | this.updateIndependentBuild();
162 |
163 | // Unlock beatmap lengths
164 | this.editorProps.locked = false;
165 |
166 | // You probably don't want to lose it
167 | window.onbeforeunload = () => "Unsaved beatmap - leave anyway?";
168 | }
169 |
170 | updateIndependentBuild() {
171 | // Force independent build if only 1 source is present
172 |
173 | // Effectively `buildup ^ loop` - does only 1 exist?
174 | let hasBuild = !!this.editorProps.song?.build?.sound;
175 | let hasLoop = !!this.editorProps.song?.loop.sound;
176 | if (hasBuild != hasLoop) {
177 | this.setIndependentBuild(true);
178 | }
179 | }
180 |
181 | setIndependentBuild(indep: boolean) {
182 | this.editorProps.song!.independentBuild = indep;
183 | }
184 |
185 | async generateXML(root?: xmlbuilder.XMLNode) {
186 | if (!this.editorProps.song) {
187 | return null;
188 | }
189 |
190 | if (!root) {
191 | root = (await _xmlbuilder).begin();
192 | }
193 |
194 | this.editorProps.song.generateXML(root);
195 |
196 | return root.end({ pretty: true });
197 | }
198 |
199 | downloadURI(uri: string, filename: string) {
200 | // http://stackoverflow.com/a/18197341
201 | let element = document.createElement("a");
202 | element.setAttribute("href", uri);
203 | element.setAttribute("download", filename);
204 |
205 | element.style.display = "none";
206 | document.body.appendChild(element);
207 |
208 | element.click();
209 |
210 | document.body.removeChild(element);
211 | }
212 |
213 | async saveZIP() {
214 | let result = await this.generateXML((await _xmlbuilder).create("songs"));
215 | if (!result) {
216 | return;
217 | }
218 |
219 | const zip = await _zip;
220 |
221 | const zipWriter = new zip.ZipWriter(
222 | new zip.Data64URIWriter("application/zip"),
223 | );
224 | await zipWriter.add("songs.xml", new zip.TextReader(result));
225 | await this.editorProps.song!.addZipAssets(zipWriter);
226 |
227 | const dataURI = await zipWriter.close();
228 |
229 | this.downloadURI(
230 | dataURI,
231 | "0x40Hues - " + this.editorProps.song!.loop.basename + ".zip",
232 | );
233 |
234 | window.onbeforeunload = null;
235 | }
236 |
237 | async saveXML() {
238 | let result = await this.generateXML((await _xmlbuilder).create("songs"));
239 | if (!result) {
240 | return;
241 | }
242 |
243 | this.downloadURI(
244 | "data:text/plain;charset=utf-8," + encodeURIComponent(result),
245 | "0x40Hues - " + this.editorProps.song!.loop.basename + ".xml",
246 | );
247 |
248 | window.onbeforeunload = null;
249 | }
250 |
251 | // http://stackoverflow.com/a/30810322
252 | async copyXML() {
253 | let text = await this.generateXML();
254 |
255 | // Clicking when disabled
256 | if (!text) {
257 | return;
258 | }
259 |
260 | let textArea = document.createElement("textarea");
261 | textArea.className = "copybox";
262 |
263 | textArea.value = text;
264 |
265 | document.body.appendChild(textArea);
266 |
267 | textArea.select();
268 |
269 | let success;
270 |
271 | try {
272 | success = document.execCommand("copy");
273 | } catch (err) {
274 | success = false;
275 | }
276 |
277 | document.body.removeChild(textArea);
278 | if (success) {
279 | this.editor.alert("Beatmap XML copied to clipboard!");
280 | } else {
281 | this.editor.alert("Copy failed! Try saving instead");
282 | }
283 | }
284 | }
285 |
--------------------------------------------------------------------------------
/src/js/HuesSettings.svelte.ts:
--------------------------------------------------------------------------------
1 | import { mount, untrack } from "svelte";
2 | import "../css/hues-settings.css";
3 | import EventListener from "./EventListener";
4 |
5 | import SettingsUI from "./HuesSettingsUI.svelte";
6 | import type HuesWindow from "./HuesWindow";
7 |
8 | /* If you're modifying settings for your hues, DON'T EDIT THIS
9 | - Go to the HTML and edit the `defaults` object instead!
10 | */
11 | const defaultSettings: SettingsData = {
12 | // List of respacks to load
13 | respacks: [],
14 | // If true, the query string (?foo=bar&baz=boz) will be parsed for settings
15 | parseQueryString: true,
16 | // ONLY USED FOR QUERY STRINGS this will be prepended to any respacks
17 | // passed in as a ?packs=query
18 | respackPath: "respacks/",
19 | // Debugging var, for loading zips or not
20 | load: true,
21 | // Debug, play first song automatically?
22 | autoplay: true,
23 | // If true, defaults passed in initialiser override locally saved
24 | overwriteLocal: false,
25 | // If set, will attempt to play the named song first
26 | firstSong: null,
27 | // If set, will attempt to set the named image first
28 | firstImage: null,
29 | // set to false to never change images
30 | fullAuto: true,
31 | // The remote respack listing JSON endpoint
32 | // NOTE: Any packs referenced need CORS enabled or loads fail
33 | packsURL: "https://cdn.0x40hu.es/getRespacks.php",
34 | // If set, will disable the remote resources menu. For custom pages.
35 | disableRemoteResources: false,
36 | // You will rarely want to change this. Enables/disables the Hues Window.
37 | enableWindow: true,
38 | // Whether to show the Hues Window on page load
39 | showWindow: false,
40 | // What tab will be displayed first in the Hues Window
41 | firstWindow: "INFO",
42 | // Preloader customisation
43 | preloadPrefix: "0x",
44 | preloadBase: 16,
45 | preloadMax: 0x40,
46 | preloadTitle: "",
47 | // Info customisation
48 | huesName: "0x40 Hues of JS, v%VERSION%",
49 | huesDesc: `0x40 Hues has some music and a few images, and the
50 | music plays and the images change.
51 | This is such a fine idea, like wowzers.
52 | Som- many like it!`,
53 | // If unset, uses , otherwise sets which element to turn hues-y
54 | root: null,
55 | // If set, keyboard shortcuts are ignored
56 | disableKeyboard: false,
57 |
58 | // UI accessible config
59 | smartAlign: "on",
60 | blurAmount: "medium",
61 | blurDecay: "fast",
62 | blurQuality: "medium",
63 | currentUI: "modern",
64 | colourSet: "normal",
65 | blendMode: "hard-light",
66 | bgColour: "transparent",
67 | blackoutUI: "off",
68 | invertStyle: "everything",
69 | playBuildups: "on",
70 | visualiser: "off",
71 | shuffleImages: "on",
72 | autoSong: "off",
73 | autoSongDelay: 5, // loops or minutes depending on autoSong value
74 | autoSongShuffle: "on",
75 | autoSongFadeout: "on",
76 | trippyMode: "off",
77 | volume: 0.7,
78 | skipPreloader: "off",
79 | };
80 |
81 | // for the UI accessible config only
82 | export const uiSettingsOptions = {
83 | smartAlign: {
84 | name: "Smart Align images",
85 | options: ["off", "on"],
86 | },
87 | blurAmount: {
88 | name: "Blur amount",
89 | options: ["low", "medium", "high"],
90 | },
91 | blurDecay: {
92 | name: "Blur decay",
93 | options: ["slow", "medium", "fast", "faster!"],
94 | },
95 | blurQuality: {
96 | name: "Blur quality",
97 | options: ["low", "medium", "high", "extreme"],
98 | },
99 | visualiser: {
100 | name: "Spectrum analyser",
101 | options: ["off", "on"],
102 | },
103 | currentUI: {
104 | name: "UI style",
105 | options: ["retro", "v4.20", "modern", "xmas", "hlwn", "mini"],
106 | },
107 | colourSet: {
108 | name: "Colour set",
109 | options: ["normal", "pastel", "v4.20"],
110 | },
111 | blendMode: {
112 | name: "Blend mode",
113 | options: ["hard-light", "screen", "multiply"],
114 | },
115 | bgColour: {
116 | name: "Render backdrop",
117 | options: ["white", "black", "transparent"],
118 | },
119 | blackoutUI: {
120 | name: "Blackout affects UI",
121 | options: ["off", "on"],
122 | },
123 | invertStyle: {
124 | name: "Invert affects",
125 | options: ["everything", "image"],
126 | },
127 | playBuildups: {
128 | name: "Play buildups",
129 | options: ["off", "once", "on"],
130 | },
131 | autoSong: {
132 | name: "AutoSong",
133 | options: ["off", "loop", "time"],
134 | },
135 | autoSongShuffle: {
136 | name: "AutoSong shuffle",
137 | options: ["off", "on"],
138 | },
139 | autoSongFadeout: {
140 | name: "AutoSong fade out",
141 | options: ["off", "on"],
142 | },
143 | trippyMode: {
144 | name: "Trippy Mode",
145 | options: ["off", "on"],
146 | },
147 | shuffleImages: {
148 | name: "Shuffle images",
149 | options: ["off", "on"],
150 | },
151 | skipPreloader: {
152 | name: "Skip preloader warning",
153 | options: ["off", "on"],
154 | },
155 | } as const; // this magic little thing lets us use "options" as a tuple type!
156 |
157 | export type SettingsData = {
158 | respacks: string[];
159 | parseQueryString: boolean;
160 | respackPath: string;
161 | load: boolean;
162 | autoplay: boolean;
163 | overwriteLocal: boolean;
164 | firstSong: string | null;
165 | firstImage: string | null;
166 | fullAuto: boolean;
167 | packsURL: string;
168 | disableRemoteResources: boolean;
169 | enableWindow: boolean;
170 | showWindow: boolean;
171 | firstWindow: string;
172 | preloadPrefix: string;
173 | preloadBase: number;
174 | preloadMax: number;
175 | preloadTitle: string;
176 | huesName: string;
177 | huesDesc: string;
178 | root: HTMLElement | string | null;
179 | disableKeyboard: boolean;
180 |
181 | // UI accessible config
182 | smartAlign: (typeof uiSettingsOptions.smartAlign.options)[number];
183 | blurAmount: (typeof uiSettingsOptions.blurAmount.options)[number];
184 | blurDecay: (typeof uiSettingsOptions.blurDecay.options)[number];
185 | blurQuality: (typeof uiSettingsOptions.blurQuality.options)[number];
186 | currentUI: (typeof uiSettingsOptions.currentUI.options)[number];
187 | colourSet: (typeof uiSettingsOptions.colourSet.options)[number];
188 | blendMode: (typeof uiSettingsOptions.blendMode.options)[number];
189 | bgColour: (typeof uiSettingsOptions.bgColour.options)[number];
190 | blackoutUI: (typeof uiSettingsOptions.blackoutUI.options)[number];
191 | invertStyle: (typeof uiSettingsOptions.invertStyle.options)[number];
192 | playBuildups: (typeof uiSettingsOptions.playBuildups.options)[number];
193 | visualiser: (typeof uiSettingsOptions.visualiser.options)[number];
194 | shuffleImages: (typeof uiSettingsOptions.shuffleImages.options)[number];
195 | autoSong: (typeof uiSettingsOptions.autoSong.options)[number];
196 | autoSongShuffle: (typeof uiSettingsOptions.autoSongShuffle.options)[number];
197 | autoSongFadeout: (typeof uiSettingsOptions.autoSongFadeout.options)[number];
198 | trippyMode: (typeof uiSettingsOptions.trippyMode.options)[number];
199 | skipPreloader: (typeof uiSettingsOptions.skipPreloader.options)[number];
200 | autoSongDelay: number;
201 | volume: number;
202 | };
203 |
204 | function isEphemeral(setting: keyof SettingsData) {
205 | return !uiSettingsOptions.hasOwnProperty(setting);
206 | }
207 |
208 | function getQuerySettings(settings: SettingsData) {
209 | let results: Partial & { respacks: string[] } = {
210 | respacks: [],
211 | };
212 | let query = window.location.search.substring(1);
213 | let vars = query.split("&");
214 | for (let i = 0; i < vars.length; i++) {
215 | let pair = vars[i].split("=");
216 | let val: string | boolean = decodeURIComponent(pair[1]);
217 | if (pair[0] == "packs" || pair[0] == "respacks") {
218 | let packs = val.split(",");
219 | for (let j = 0; j < packs.length; j++) {
220 | results.respacks.push(settings.respackPath + packs[j]);
221 | }
222 | } else if (pair[0] == "song") {
223 | // alias for firstSong
224 | results.firstSong = val;
225 | } else {
226 | // since we can set ephemeral variables this way
227 | if (val === "true" || val === "false") val = val == "true";
228 | (results)[pair[0]] = val;
229 | }
230 | }
231 | return results;
232 | }
233 |
234 | export type SettingsDataWithUpdate = SettingsData & { onupdate?: () => void };
235 |
236 | export function makeSettings(defaults: Partial) {
237 | const settings: SettingsDataWithUpdate = $state({ ...defaultSettings });
238 |
239 | let settingsVersion = "1";
240 | if (localStorage.settingsVersion != settingsVersion) {
241 | localStorage.clear();
242 | localStorage.settingsVersion = settingsVersion;
243 | }
244 |
245 | const settingsKeys = Object.keys(defaultSettings) as (keyof SettingsData)[];
246 |
247 | for (const attr of settingsKeys) {
248 | // this is too tricky for typescript and/or my brain, so just be
249 | // lazy with the
250 | if (defaults.overwriteLocal) {
251 | (settings)[attr] =
252 | localStorage[attr] ?? defaults[attr] ?? defaultSettings[attr];
253 | } else {
254 | (settings)[attr] =
255 | defaults[attr] ?? localStorage[attr] ?? defaultSettings[attr];
256 | }
257 | }
258 |
259 | if (settings.parseQueryString) {
260 | let querySettings = getQuerySettings(settings);
261 |
262 | for (const attr of settingsKeys) {
263 | // query string overrides, finally
264 | if (querySettings[attr] !== undefined && attr != "respacks") {
265 | (settings)[attr] = querySettings[attr];
266 | }
267 | }
268 |
269 | settings.respacks = settings.respacks.concat(querySettings.respacks);
270 | }
271 |
272 | // attach our event listeners
273 | $effect.root(() => {
274 | for (const attr of settingsKeys) {
275 | let first = true;
276 | $effect(() => {
277 | settings[attr]; // ensure deps are caught
278 | if (first) {
279 | first = false;
280 | return;
281 | }
282 |
283 | console.debug(`Setting updated ${attr} -> ${settings[attr]}`);
284 |
285 | if (!isEphemeral(attr)) localStorage[attr] = settings[attr];
286 |
287 | untrack(() => settings.onupdate?.());
288 | });
289 | }
290 | });
291 |
292 | return settings;
293 | }
294 |
295 | export function initUI(window: HuesWindow, settings: SettingsData) {
296 | const settingsTab = window.addTab("OPTIONS");
297 | mount(SettingsUI, {
298 | target: settingsTab,
299 | props: {
300 | schema: uiSettingsOptions,
301 | settings,
302 | },
303 | });
304 | }
305 |
--------------------------------------------------------------------------------
/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/js/HuesEditor/EditorBox.svelte:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
351 |
352 |
353 |
354 |
411 |
412 |
413 | {#if showHelp && !section?.sound}
414 |
415 |
416 | Click [LOAD RHYTHM] to load a loop!
417 | OGG,or LAME encoded MP3s work best.
418 |
419 | You can also add a buildup with
420 | [LOAD BUILDUP], or remove it with [REMOVE].
421 |
422 | [NEW SONG] adds a totally empty song to edit.
423 |
424 | [COPY/SAVE XML] allow for storing the rhythms
425 | and easy inclusion into a Resource Pack!
426 |
427 | Click [HELP] for advanced techniques and more
428 | information.
429 |
430 | {:else if !section?.sound}
431 |
[none]
432 |
433 | {:else if beatIndex !== null && beatIndex >= 0 && activeBanks}
434 | {#each activeBanks as _, i}
435 |
436 | {@html " ".repeat(beatIndex) + "█"}
437 |
438 | {/each}
439 | {/if}
440 | {#if section?.sound && activeBanks}
441 | {#if activeBanks.length > 1}
442 |
445 |
446 | {@html "█".repeat(section.mapLen)}
447 |
448 | {/if}
449 |
450 | {#each activeBanks as bankI, i}
451 |
1 && i == activeBanks.length - 1}
454 | class:hover={bankI == bankHover}
455 | contenteditable
456 | spellcheck="false"
457 | class="beatmap"
458 | style={styleForMap(i)}
459 | on:keydown={banEnter}
460 | on:keyup={handleArrows}
461 | on:paste={handlePaste}
462 | on:input={saveLen}
463 | bind:textContent={section.banks[bankI]}
464 | on:input={handleInput}
465 | on:contextmenu={seek}
466 | on:mousedown={click}
467 | on:click={click}
468 | on:focus={() => dispatch("focus")}
469 | >
470 | {/each}
471 | {/if}
472 |
473 |
474 |
542 |
--------------------------------------------------------------------------------
/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.svelte";
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/Main.svelte:
--------------------------------------------------------------------------------
1 |
408 |
409 |
410 |
411 |
412 | {#if totallyDisabled}
413 |
415 | {#await songLoadPromise catch}
{/await}
416 |
417 |
418 |
Ready to go?
419 |
{
421 | totallyDisabled = false;
422 | }}
423 | >
424 | Activate Editor
425 |
426 |
427 | {:else}
428 |
429 |
459 |
460 |
461 |
462 |
463 | song?.title ?? "",
466 | (v) => {
467 | if (song) song.title = v;
468 | }
469 | }
470 | label="Title:"
471 | placeholder="Song name"
472 | {disabled}
473 | />
474 | song?.source ?? "",
477 | (v) => {
478 | if (song) song.source = v;
479 | }
480 | }
481 | label="Link:"
482 | placeholder="Source link (YouTube, Soundcloud, etc)"
483 | {disabled}
484 | />
485 |
486 |
487 |
488 |
489 |
490 | {#await songLoadPromise}
491 |
492 |
493 |
494 |
495 | {:then}
496 |
501 |
506 |
511 |
516 | {:catch}
517 |
518 |
519 |
520 |
521 | {/await}
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
song?.independentBuild,
532 | (v) => {
533 | if (song) song.independentBuild = v ?? false;
534 | }
535 | }
536 | on:click={() => {
537 | pushUndo();
538 | resyncEditorLengths(loopEditorComponent);
539 | }}
540 | disabled={!hasBoth}
541 | />
542 |
543 |
544 |
BANKS
545 | {#if song?.loop}
546 | {#each song.loop.banks as bank, i}
547 |
{
551 | song.hiddenBanks[i] = !song.hiddenBanks[i];
552 | }}
553 | >
554 | {i + 1}
555 | {@html song.hiddenBanks[i]
557 | ? HuesIcon.EYE_CLOSED
558 | : " "}
560 |
561 | {#if song.loop.banks.length == 1}
562 |
563 | {:else}
564 |
{
569 | pushUndo();
570 | song.removeBank(i);
571 | }}
572 | >
573 | x
574 |
575 | {/if}
576 | {/each}
577 | {#if song.loop.banks.length < 16}
578 |
579 | {
582 | pushUndo();
583 | song.addBank();
584 | }}
585 | >
586 | +
587 |
588 |
589 | {/if}
590 | {/if}
591 |
592 |
593 | song?.build,
598 | (v) => {
599 | if (song) song.build = v;
600 | }
601 | }
602 | bind:editBox={buildupEditBox}
603 | bind:locked
604 | on:rewind={() => soundManager.seek(-soundManager.build.length)}
605 | on:seek={(event) =>
606 | soundManager.seek(-soundManager.build.length * (1 - event.detail))}
607 | on:error={(event) => alert(event.detail)}
608 | on:songload={(event) => dispatch("loadbuildup", event.detail)}
609 | on:double={doubleClicked}
610 | on:halve={halveClicked}
611 | on:beforeinput={editorBeforeInput}
612 | on:afterinput={editorAfterInput}
613 | on:focus={editorOnfocus}
614 | on:songremove={(event) => dispatch("removebuildup")}
615 | on:songnew
616 | beatIndex={buildIndex}
617 | {newLineAtBeat}
618 | {soundManager}
619 | hiddenBanks={song?.hiddenBanks}
620 | />
621 |
622 |
628 |
629 |
{@html HuesIcon.MENU}
630 |
631 |
632 | song?.loop,
638 | (v) => {
639 | if (song) song.loop = v!;
640 | }
641 | }
642 | bind:header={loopHeader}
643 | bind:editBox={loopEditBox}
644 | bind:locked
645 | on:rewind={() => soundManager.seek(0)}
646 | on:seek={(event) =>
647 | soundManager.seek(soundManager.loop.length * event.detail)}
648 | on:error={(event) => alert(event.detail)}
649 | on:songload={(event) => dispatch("loadrhythm", event.detail)}
650 | on:double={doubleClicked}
651 | on:halve={halveClicked}
652 | on:beforeinput={editorBeforeInput}
653 | on:afterinput={editorAfterInput}
654 | on:focus={editorOnfocus}
655 | on:songremove={(event) => dispatch("removerhythm")}
656 | on:songnew
657 | beatIndex={loopIndex}
658 | {newLineAtBeat}
659 | {soundManager}
660 | hiddenBanks={song?.hiddenBanks}
661 | />
662 |
663 |
664 |
665 | {#each Object.entries(beatGlossary) as [category, beats]}
666 |
667 | {category}
668 | {#each beats as beat}
669 | {
672 | if (editorFocus) editorFocus.fakeInput(beat[0]);
673 | }}
674 | nouppercase
675 | disabled={!editorFocussed}
676 | >
677 | {beat[0]}
678 |
679 | {/each}
680 |
681 | {/each}
682 |
683 |
684 |
685 |
686 |
687 |
{
690 | changeRate(-0.25);
691 | }}>{@html HuesIcon.BACKWARD}
693 |
694 |
{
697 | changeRate(0.25);
698 | }}>{@html HuesIcon.FORWARD}
700 |
701 |
702 |
703 |
704 |
{playbackRate.toFixed(2)}x
705 |
New line at beat
706 |
712 |
713 |
714 |
715 |
716 | {#await songLoadPromise}
717 |
718 | {:then}
719 |
720 | {:catch}
721 |
722 | {/await}
723 | {/if}
724 |
725 |
726 |
933 |
--------------------------------------------------------------------------------