├── 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 | 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 | 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 | 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 | 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 | ![](docs/img/hues_default.png)](https://0x40.mon.im/) 11 | [420 Hues 12 | ![](docs/img/hues_420.png)](https://420.mon.im/) 13 | [Halloween Hues 14 | ![](docs/img/hues_hlwn.png)](https://spook.mon.im/) 15 | [Christmas Hues 16 | ![](docs/img/hues_xmas.png)](https://xmas.moe/) 17 | 18 | You can also have animations that sync perfectly with the beats of the songs. Inspired by Kepstin's Integral experiments. 19 | [420 Hues, Snoop Edition 20 | ![](docs/img/hues_snoop.png)](https://420.mon.im/snoop.html) 21 | ["Montegral" 22 | ![](docs/img/hues_montegral.png)](https://0x40.mon.im/montegral.html) 23 | [More Cowbell 24 | ![](docs/img/hues_cowbell.png)](https://0x40.mon.im/cowbell.html) 25 | 26 | For some examples of **fast, complicated and fancy** maps, here are some of my personal creations: 27 | [Black Banshee - BIOS](https://0x40.mon.im/custom.html?packs=BIOS.zip) 28 | [Drop It](https://0x40.mon.im/custom.html?packs=drop_it.zip) 29 | [Atols - Eden (buildup only)](https://0x40.mon.im/custom.html?packs=eden.zip) 30 | [AAAA - Hop Step Adventure](https://0x40.mon.im/custom.html?packs=hopstep.zip) 31 | [MACROSS 82-99 - ミュン・ファン・ローン](https://0x40.mon.im/custom.html?packs=macross.zip) 32 | [MDK - Press Start (VIP Mix)](https://0x40.mon.im/custom.html?packs=press_start.zip) 33 | [Alex Centra - Roguebot [Inspected]](https://0x40.mon.im/custom.html?packs=roguebot.zip) 34 | [Elenne - Vertical Smoke](https://0x40.mon.im/custom.html?packs=smoke.zip) 35 | [Nicky Flower - Wii Shop Channel (Remix)](https://0x40.mon.im/custom.html?packs=wii_remix.zip) 36 | [Nhato - Logos](https://0x40.mon.im/custom.html?packs=logos.zip) 37 | [Massive New Krew - HADES](https://0x40.mon.im/custom.html?packs=HADES.zip) 38 | 39 | Finally there's these, which hook into the Hues javascript events to do something fresh: 40 | [Doors](https://0x40.mon.im/doors.html) 41 | [Does Lewis Have A Girlfriend Yet (xox love ya)](https://0x40.mon.im/lewis.html) 42 | 43 | ## Creating your own songs 44 | 45 | 0x40 Hues comes with an integrated editor to create new songs and inspect existing ones. 46 | [Read how to use it here](https://github.com/mon/0x40-web/blob/master/docs/Editor.md) - it's easier than you think! 47 | 48 | ## Editing respacks 49 | 50 | There is an extremely basic respack editor at respack_edit.html. I also host it 51 | on [my site](https://0x40.mon.im/respack_edit.html). It does not support adding 52 | images, nor does it support adding songs. You can, however, edit all properties 53 | of an existing respack's songs and images. If this is lacking features you would 54 | like, please open a ticket. It was mostly made for editing centerPixel values. 55 | 56 | ## Install (Make your own Hues website) 57 | 58 | 1. Start by downloading the latest [release](https://github.com/mon/0x40-web/releases) 59 | 2. Put your respack zips somewhere they can be found by your web server. My hues have a `respacks/` folder under the main directory 60 | 3. Edit `index.html`: 61 | 4. Edit the `defaults` object so the `respacks` list contains the respacks you wish to load 62 | 5. _Optional:_ Add any extra settings to the `defaults` object 63 | 6. Upload everything to your server! 64 | 65 | ### Example settings 66 | 67 | ```javascript 68 | var defaults = { 69 | respacks: ["./respacks/Defaults_v5.0_Opaque.zip", "./respacks/HuesMixA.zip"], 70 | firstSong: "Nhato - Miss You", 71 | }; 72 | ``` 73 | 74 | ## Settings object 75 | 76 | See [HuesSettings.ts](./src/js/HuesSettings.ts#L10) for the possible options you 77 | can put into the `defaults` object. 78 | 79 | ## Query string 80 | 81 | Any setting that can go in the `defaults` object can also be dynamically specified in the URL. 82 | For example: https://0x40.mon.im/custom.html?packs=BIOS.zip,kitchen.zip¤tUI=v4.20 83 | 84 | There are two special settings here: 85 | 86 | - `firstSong` can just be written as `song`. 87 | - Anything given as `packs` or `respacks` will be appended to the respacks 88 | specified in the `defaults` object, as opposed to overwriting them. 89 | 90 | ## Building 91 | 92 | Install [Node.js](https://nodejs.org/en/). I currently use v18, but it should 93 | work with newer releases. 94 | 95 | Install the required packages for the build: 96 | 97 | ```bash 98 | npm install 99 | ``` 100 | 101 | Build with `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 | 38 | or... 39 | 45 |
46 | 47 | {#if pack} 48 |
49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 | 66 | 67 | 68 |
Songs: {pack.songs.length}
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | {#each pack.songs as song} 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 101 | 102 | {/each} 103 | 104 |
TitleSourceBanksLoop lenLoop fileBuild lenBuild filecharsPerBeatindependentBuild
{song.bankCount}{song.loop.mapLen}{song.loop.filename}{song.build ? song.build.mapLen : "n/a"}{song.build ? song.build.filename : "n/a"}{song.charsPerBeat} 95 | {#if song.build} 96 | 97 | {:else} 98 | n/a 99 | {/if} 100 |
105 | 106 |
Images: {pack.images.length}
107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | {#each pack.images as image} 122 | 123 | 124 | 125 | 126 | 133 | 134 | 141 | 148 | 155 | 156 | {/each} 157 | 158 |
NameFull nameSourceAlignImg #frameDurationsbeatsPerAnimsyncOffset
127 | 132 | {image.bitmaps.length} 135 | {#if image.animated} 136 | 137 | {:else} 138 | n/a 139 | {/if} 140 | 142 | {#if image.animated} 143 | 144 | {:else} 145 | n/a 146 | {/if} 147 | 149 | {#if image.animated} 150 | 151 | {:else} 152 | n/a 153 | {/if} 154 |
159 |
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 | 125 | 126 | {#if selectedImage} 127 |
128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 140 | 141 | 142 | 148 | {#if selectedImage.centerPixel !== undefined} 149 | 155 | 162 | {/if} 163 |
164 |
165 | 166 | 167 | 168 | 169 | 178 | 186 | 187 |
188 | 189 | 194 | 196 | 197 | 198 | 203 | 204 | 205 | 210 | 211 | 212 | 220 | 221 | {#if canvas} 222 | 223 | 228 | {/if} 229 |
230 | 231 | 232 |
237 |
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 |
355 | 356 | {@html title} 357 | 358 | dispatch("rewind")}>{@html HuesIcon.REWIND} 363 | {section ? section.mapLen : 0} beats 364 | 365 | 371 | {@html locked ? HuesIcon.LOCKED : HuesIcon.UNLOCKED} 372 | 373 | 374 | copyPretty()}>{@html HuesIcon.COPY} 380 | 381 | 382 |
383 | 384 | dispatch("halve")} 386 | disabled={!section?.sound || locked || section.mapLen <= 1} 387 | > 388 | Halve 389 | 390 | dispatch("double")} 392 | disabled={!section?.sound || locked} 393 | > 394 | Double 395 | 396 | 403 | fileInput.click()}> 404 | 405 |
Load {title}
406 |
407 | Remove 410 |
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 |
430 | dispatch("songnew")}>New Song 431 | dispatch("savezip")} {disabled} 432 | >Save ZIP 434 | dispatch("savexml")} {disabled} 435 | >Save XML 437 | dispatch("copyxml")} {disabled} 438 | >Copy XML 440 | undo()} 442 | disabled={!song?.undoQueue || !song.undoQueue.length}>Undo 444 | redo()} 446 | disabled={!song?.redoQueue || !song.redoQueue.length}>Redo 448 | (helpGlow = false)}> 449 | Help? 453 | 454 | 455 | {#key statusAnim} 456 | {statusMsg} 457 | {/key} 458 |
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 | --------------------------------------------------------------------------------