Compare commits
33 Commits
81870ecd47
...
d313108f1a
| Author | SHA1 | Date |
|---|---|---|
|
|
d313108f1a | |
|
|
835d69d2d0 | |
|
|
4442daae53 | |
|
|
cfff2b808a | |
|
|
b8accefff7 | |
|
|
b8ef4d7937 | |
|
|
dcae12ab36 | |
|
|
ffa2e952c9 | |
|
|
e05c3b2471 | |
|
|
d59c5a20c1 | |
|
|
b1d449cf02 | |
|
|
3f06cc9aa6 | |
|
|
a5659fcb09 | |
|
|
6271a377bd | |
|
|
a4cee92d00 | |
|
|
f83f2a72ba | |
|
|
afb3e34e71 | |
|
|
ebd7811b12 | |
|
|
a64d671527 | |
|
|
7eaa5fce75 | |
|
|
da86ca6a2d | |
|
|
c4c1919df6 | |
|
|
869d982b1e | |
|
|
10839ba22c | |
|
|
398de3dc04 | |
|
|
4f432968ef | |
|
|
56cea50a65 | |
|
|
0d416c6f5a | |
|
|
c1d91839e4 | |
|
|
76189c6ad2 | |
|
|
b6f576d50d | |
|
|
a4ca1c86ec | |
|
|
38c9472cb1 |
|
|
@ -3,3 +3,6 @@
|
||||||
# IDE0290: Use primary constructor
|
# IDE0290: Use primary constructor
|
||||||
# Primary constructors are far from perfect: they can't have readonly fields, while fields can be used anywhere in the class body.
|
# Primary constructors are far from perfect: they can't have readonly fields, while fields can be used anywhere in the class body.
|
||||||
csharp_style_prefer_primary_constructors = false
|
csharp_style_prefer_primary_constructors = false
|
||||||
|
|
||||||
|
# IDE0305: Simplify collection initialization
|
||||||
|
dotnet_style_prefer_collection_expression = never
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"vitest.explorer",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
{
|
||||||
|
// https://github.com/tailwindlabs/tailwindcss/discussions/5258#discussioncomment-1979394
|
||||||
|
"css.customData": [
|
||||||
|
".vscode/tailwind.json"
|
||||||
|
],
|
||||||
|
// Disable the default formatter, use eslint instead
|
||||||
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
// Auto fix
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||||
|
"eslint.rules.customizations": [
|
||||||
|
{
|
||||||
|
"rule": "style/*",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "format/*",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*-indent",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*-spacing",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*-spaces",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*-order",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*-dangle",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*-newline",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*quotes",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "*semi",
|
||||||
|
"severity": "off",
|
||||||
|
"fixable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Enable eslint for all supported languages
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"yaml",
|
||||||
|
"toml",
|
||||||
|
"xml",
|
||||||
|
"gql",
|
||||||
|
"graphql",
|
||||||
|
"astro",
|
||||||
|
"svelte",
|
||||||
|
"css",
|
||||||
|
"less",
|
||||||
|
"scss",
|
||||||
|
"pcss",
|
||||||
|
"postcss"
|
||||||
|
],
|
||||||
|
"workspaceKeybindings.manimPreviewTask.enabled": true,
|
||||||
|
"typescript.format.enable": false,
|
||||||
|
"typescript.tsdk": "./Frontend/node_modules/typescript/lib",
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
{
|
||||||
|
"version": 4.0,
|
||||||
|
"atDirectives": [
|
||||||
|
{
|
||||||
|
"name": "@theme",
|
||||||
|
"description": "Use the `@theme` directive to define your project's custom design tokens, like fonts, colors, and breakpoints.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#theme-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@source",
|
||||||
|
"description": "Use the `@source` directive to explicitly specify source files that aren't picked up by Tailwind's automatic content detection.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#source-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@utility",
|
||||||
|
"description": "Use the `@utility` directive to add custom utilities to your project that work with variants like `hover`, `focus` and `lg`.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#utility-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@variant",
|
||||||
|
"description": "Use the `@variant` directive to apply a Tailwind variant to styles in your CSS.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#variant-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@custom-variant",
|
||||||
|
"description": "Use the `@custom-variant` directive to add a custom variant in your project.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#custom-variant-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@apply",
|
||||||
|
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#apply-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@reference",
|
||||||
|
"description": "If you want to use `@apply` or `@variant` in the `<style>` block of a Vue or Svelte component, or within CSS modules, you will need to import your theme variables, custom utilities, and custom variants to make those values available in that context.\n\nTo do this without duplicating any CSS in your output, use the `@reference` directive to import your main stylesheet for reference without actually including the styles.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#reference-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@config",
|
||||||
|
"description": "Use the `@config` directive to load a legacy JavaScript-based configuration file.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#config-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@plugin",
|
||||||
|
"description": "Use the `@plugin` directive to load a legacy JavaScript-based plugin.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#plugin-directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
24
CHANGELOG.md
|
|
@ -1,7 +1,29 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## MuzikaGromche 1337.9001.2
|
## MuzikaGromche 1337.9001.67 - LocalHost Edition
|
||||||
|
|
||||||
|
- Added a new track TwoFastTuFurious (from the same artist as PickUpSticks), thematic to the upcoming Valentine's Day.
|
||||||
|
- Added support for client-side playback while playing with an unmodded/vanilla host.
|
||||||
|
- Tweaked the amount of visual flare at the Factory's start room (main tile).
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.9001.4 - v73 Chinese New Year Edition
|
||||||
|
|
||||||
|
- Remastered recently added track IkWilJe using a higher quality source audio and better fitting visual effects.
|
||||||
|
- Adjusted lyrics for PWNED (can't believe it missed an obvious joke).
|
||||||
|
- Added a new track Paarden.
|
||||||
|
- Added a new track DiscoKapot.
|
||||||
|
- Added an accessibility option to reduce the intensity of overly distracting visual effects.
|
||||||
|
- Seasonal content like New Year's songs (IkWilJe, Paarden, DiscoKapot) will only be available for selection during their respective seasons.
|
||||||
|
- Reduced memory usage by almost 400 MB, thanks to loading audio clips on demand (not preloading all tracks at launch).
|
||||||
|
- Added a new track PickUpSticks.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.9001.3 - v73 Happy New Year Edition
|
||||||
|
|
||||||
|
- Added a new track IkWilJe.
|
||||||
|
|
||||||
|
## MuzikaGromche 1337.9001.2 - v73 Rushed Edition
|
||||||
|
|
||||||
|
- Added a new track HighLow.
|
||||||
|
|
||||||
## MuzikaGromche 1337.9001.1 - v73 Music louder Edition
|
## MuzikaGromche 1337.9001.1 - v73 Music louder Edition
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
|
|
@ -44,6 +44,12 @@ test/core/html/
|
||||||
explainFiles.txt
|
explainFiles.txt
|
||||||
.vitest-dump
|
.vitest-dump
|
||||||
|
|
||||||
|
# ESLint
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Vite CSS Modules
|
||||||
|
*.module.css.d.ts
|
||||||
|
|
||||||
# Project assets
|
# Project assets
|
||||||
/public/MuzikaGromcheAudio/*
|
/public/MuzikaGromcheAudio/*
|
||||||
!/public/MuzikaGromcheAudio/.gitkeep
|
!/public/MuzikaGromcheAudio/.gitkeep
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"recommendations": ["Vue.volar"]
|
|
||||||
}
|
|
||||||
|
|
@ -19,10 +19,13 @@ The look & feel is inspired by a certain popular NLE (Non-Linear video Editor) w
|
||||||
4. Run the following script to generate bare codenames file:
|
4. Run the following script to generate bare codenames file:
|
||||||
```sh
|
```sh
|
||||||
cat ./MuzikaGromcheTracks.json | jq '[.tracks[].Name | {(.): { "Artist": "", "Song": "" }}] | add' > MuzikaGromcheCodenamesBare.json
|
cat ./MuzikaGromcheTracks.json | jq '[.tracks[].Name | {(.): { "Artist": "", "Song": "" }}] | add' > MuzikaGromcheCodenamesBare.json
|
||||||
|
jq -s '.[0] * .[1]' MuzikaGromcheCodenamesBare.json MuzikaGromcheCodenames.json > tmp.json
|
||||||
|
rm MuzikaGromcheCodenames.json
|
||||||
|
rm MuzikaGromcheCodenamesBare.json
|
||||||
|
mv tmp.json MuzikaGromcheCodenames.json
|
||||||
```
|
```
|
||||||
5. Add new codenames from the generated file above to `public/MuzikaGromcheCodenames.json` file.
|
5. Add new codenames from the generated file above to `public/MuzikaGromcheCodenames.json` file.
|
||||||
|
|
||||||
|
|
||||||
### Run & test
|
### Run & test
|
||||||
|
|
||||||
First time setup:
|
First time setup:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-svg-loader" />
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
lessOpinionated: true,
|
||||||
|
ignores: [
|
||||||
|
'public/MuzikaGromcheTracks.json',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'default-case-last': 'off',
|
||||||
|
'pnpm/json-enforce-catalog': 'off',
|
||||||
|
'pnpm/yaml-enforce-settings': 'off',
|
||||||
|
'ts/consistent-type-definitions': 'off',
|
||||||
|
'no-console': 'off',
|
||||||
|
// who said I can't pass refs inside objects as props?
|
||||||
|
'vue/no-mutating-props': ['error', {
|
||||||
|
shallowOnly: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,52 +1,62 @@
|
||||||
{
|
{
|
||||||
"name": "muzika-gromche-frontend",
|
"name": "muzika-gromche-frontend",
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "node scripts/generate-icons.js",
|
"build": "run-p type-check prebuild \"build-only {@}\" --",
|
||||||
"build": "npm run prebuild && vue-tsc -b && vite build",
|
"prebuild": "tsx scripts/generate-icons.ts",
|
||||||
|
"build-only": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"coverage": "vitest run --coverage",
|
"coverage": "vitest run --coverage",
|
||||||
"test:browser": "vitest"
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "eslint . --cache",
|
||||||
|
"lint:fix": "eslint . --cache --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-design-icons/svg": "^0.14.15",
|
"@material-design-icons/svg": "^0.14.15",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@unhead/vue": "^2.1.2",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@antfu/eslint-config": "^6.6.1",
|
||||||
|
"@tsconfig/node24": "^24.0.3",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
|
"@types/node": "^24.10.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vitest/browser-playwright": "^4.0.15",
|
"@vitest/browser-playwright": "^4.0.15",
|
||||||
"@vitest/coverage-v8": "4.0.14",
|
"@vitest/coverage-v8": "4.0.15",
|
||||||
|
"@vitest/eslint-plugin": "^1.5.2",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"eslint": "~9.39.1",
|
"eslint": "~9.39.1",
|
||||||
"eslint-plugin-vue": "~10.5.1",
|
"eslint-plugin-format": "^1.1.0",
|
||||||
"sharp": "^0.33.5",
|
"eslint-plugin-vue": "~10.6.2",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"jsdom": "^27.3.0",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
"png-to-ico": "^3.0.1",
|
"png-to-ico": "^3.0.1",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "npm:rolldown-vite@7.1.14",
|
"vite": "npm:rolldown-vite@^7.2.11",
|
||||||
|
"vite-css-modules": "^1.12.0",
|
||||||
"vite-plugin-vue-devtools": "^8.0.5",
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vitest": "^4.0.15",
|
"vitest": "^4.0.15",
|
||||||
"vitest-browser-vue": "^2.0.1",
|
"vitest-browser-vue": "^2.0.1",
|
||||||
"vue-tsc": "^3.1.5"
|
"vue-tsc": "^3.1.8"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
|
||||||
},
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"core-js",
|
|
||||||
"sharp"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
catalogMode: manual
|
||||||
|
|
||||||
|
shellEmulator: true
|
||||||
|
|
||||||
|
trustPolicy: no-downgrade
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
|
|
@ -43,6 +43,10 @@
|
||||||
"Artist": "Noize MC",
|
"Artist": "Noize MC",
|
||||||
"Song": "Устрой дестрой"
|
"Song": "Устрой дестрой"
|
||||||
},
|
},
|
||||||
|
"DiscoKapot": {
|
||||||
|
"Artist": "Дискотека Авария",
|
||||||
|
"Song": "Новогодняя"
|
||||||
|
},
|
||||||
"Durochka": {
|
"Durochka": {
|
||||||
"Artist": "Би-2",
|
"Artist": "Би-2",
|
||||||
"Song": "Дурочка"
|
"Song": "Дурочка"
|
||||||
|
|
@ -55,6 +59,14 @@
|
||||||
"Artist": "Город под подошвой",
|
"Artist": "Город под подошвой",
|
||||||
"Song": "Oxxxymiron"
|
"Song": "Oxxxymiron"
|
||||||
},
|
},
|
||||||
|
"HighLow": {
|
||||||
|
"Artist": "Nirvana",
|
||||||
|
"Song": "Smells Like Teen Spirit"
|
||||||
|
},
|
||||||
|
"IkWilJe": {
|
||||||
|
"Artist": "My Chemical Romance",
|
||||||
|
"Song": "All I Want for Christmas Is You"
|
||||||
|
},
|
||||||
"Kach": {
|
"Kach": {
|
||||||
"Artist": "Black Eyed Peas",
|
"Artist": "Black Eyed Peas",
|
||||||
"Song": "Pump It"
|
"Song": "Pump It"
|
||||||
|
|
@ -71,10 +83,22 @@
|
||||||
"Artist": "One-Punch Man",
|
"Artist": "One-Punch Man",
|
||||||
"Song": "Opening"
|
"Song": "Opening"
|
||||||
},
|
},
|
||||||
|
"Paarden": {
|
||||||
|
"Artist": "Элизиум",
|
||||||
|
"Song": "Три белых коня"
|
||||||
|
},
|
||||||
"Peretasovka": {
|
"Peretasovka": {
|
||||||
"Artist": "LMFAO",
|
"Artist": "LMFAO",
|
||||||
"Song": "Party Rock Anthem"
|
"Song": "Party Rock Anthem"
|
||||||
},
|
},
|
||||||
|
"PickUpSticks1": {
|
||||||
|
"Artist": "t.A.T.u.",
|
||||||
|
"Song": "Show Me Love"
|
||||||
|
},
|
||||||
|
"PickUpSticks2": {
|
||||||
|
"Artist": "t.A.T.u.",
|
||||||
|
"Song": "Show Me Love"
|
||||||
|
},
|
||||||
"PWNED": {
|
"PWNED": {
|
||||||
"Artist": "CYBEЯIA",
|
"Artist": "CYBEЯIA",
|
||||||
"Song": "Russian Hackers"
|
"Song": "Russian Hackers"
|
||||||
|
|
@ -91,6 +115,10 @@
|
||||||
"Artist": "Витас",
|
"Artist": "Витас",
|
||||||
"Song": "Опера #2"
|
"Song": "Опера #2"
|
||||||
},
|
},
|
||||||
|
"TwoFastTuFurious": {
|
||||||
|
"Artist": "t.A.T.u.",
|
||||||
|
"Song": "Not Gonna Get Us / Нас не догонят"
|
||||||
|
},
|
||||||
"VseVZale": {
|
"VseVZale": {
|
||||||
"Artist": "Дискотека Авария",
|
"Artist": "Дискотека Авария",
|
||||||
"Song": " Х.Х.Х.И.Р.Н.Р."
|
"Song": " Х.Х.Х.И.Р.Н.Р."
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
{
|
{
|
||||||
"version": "1337.9001.2",
|
"version": "1337.9001.67",
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
"Name": "AttentionPls1",
|
"Name": "AttentionPls1",
|
||||||
"IsExplicit": true,
|
"IsExplicit": true,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 39.19,
|
"WindUpTimer": 39.19,
|
||||||
"Bpm": 97.8244247,
|
"Bpm": 97.8244247,
|
||||||
|
|
@ -68,6 +69,7 @@
|
||||||
{
|
{
|
||||||
"Name": "AttentionPls2",
|
"Name": "AttentionPls2",
|
||||||
"IsExplicit": true,
|
"IsExplicit": true,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 39.19,
|
"WindUpTimer": 39.19,
|
||||||
"Bpm": 97.8244247,
|
"Bpm": 97.8244247,
|
||||||
|
|
@ -132,6 +134,7 @@
|
||||||
{
|
{
|
||||||
"Name": "BbIXODaHET",
|
"Name": "BbIXODaHET",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 40.85,
|
"WindUpTimer": 40.85,
|
||||||
"Bpm": 84.82064,
|
"Bpm": 84.82064,
|
||||||
|
|
@ -186,6 +189,7 @@
|
||||||
{
|
{
|
||||||
"Name": "BeefLiver1",
|
"Name": "BeefLiver1",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 39.35,
|
"WindUpTimer": 39.35,
|
||||||
"Bpm": 124.999992,
|
"Bpm": 124.999992,
|
||||||
|
|
@ -332,6 +336,7 @@
|
||||||
{
|
{
|
||||||
"Name": "BeefLiver3",
|
"Name": "BeefLiver3",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 39.35,
|
"WindUpTimer": 39.35,
|
||||||
"Bpm": 124.999992,
|
"Bpm": 124.999992,
|
||||||
|
|
@ -478,6 +483,7 @@
|
||||||
{
|
{
|
||||||
"Name": "BeefLiver4",
|
"Name": "BeefLiver4",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 31.68,
|
"WindUpTimer": 31.68,
|
||||||
"Bpm": 124.999992,
|
"Bpm": 124.999992,
|
||||||
|
|
@ -612,6 +618,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Beha1",
|
"Name": "Beha1",
|
||||||
"IsExplicit": true,
|
"IsExplicit": true,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 35.23,
|
"WindUpTimer": 35.23,
|
||||||
"Bpm": 81.99027,
|
"Bpm": 81.99027,
|
||||||
|
|
@ -648,6 +655,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Beha2",
|
"Name": "Beha2",
|
||||||
"IsExplicit": true,
|
"IsExplicit": true,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 38.16,
|
"WindUpTimer": 38.16,
|
||||||
"Bpm": 81.99027,
|
"Bpm": 81.99027,
|
||||||
|
|
@ -684,6 +692,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Beha3",
|
"Name": "Beha3",
|
||||||
"IsExplicit": true,
|
"IsExplicit": true,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 35.21,
|
"WindUpTimer": 35.21,
|
||||||
"Bpm": 81.99027,
|
"Bpm": 81.99027,
|
||||||
|
|
@ -720,6 +729,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Chereshnya",
|
"Name": "Chereshnya",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 45.48,
|
"WindUpTimer": 45.48,
|
||||||
"Bpm": 131.958755,
|
"Bpm": 131.958755,
|
||||||
|
|
@ -769,6 +779,7 @@
|
||||||
{
|
{
|
||||||
"Name": "DeployDestroy",
|
"Name": "DeployDestroy",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 40.68,
|
"WindUpTimer": 40.68,
|
||||||
"Bpm": 129.878922,
|
"Bpm": 129.878922,
|
||||||
|
|
@ -928,9 +939,63 @@
|
||||||
],
|
],
|
||||||
"GameOverText": null
|
"GameOverText": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "DiscoKapot",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": "New Year",
|
||||||
|
"Language": "Russian",
|
||||||
|
"WindUpTimer": 30.3,
|
||||||
|
"Bpm": 67.01337,
|
||||||
|
"Beats": 32,
|
||||||
|
"LoopOffset": 0,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 33.933,
|
||||||
|
"FileDurationLoop": 28.651,
|
||||||
|
"FileNameIntro": "DiscoKapotIntro.ogg",
|
||||||
|
"FileNameLoop": "DiscoKapotLoop.ogg",
|
||||||
|
"BeatsOffset": 0.0,
|
||||||
|
"FadeOutBeat": -4.0,
|
||||||
|
"FadeOutDuration": 4.0,
|
||||||
|
"ColorTransitionIn": 0.25,
|
||||||
|
"ColorTransitionOut": 0.6,
|
||||||
|
"ColorTransitionEasing": "InOutExpo",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
-32.0,
|
||||||
|
-24.0,
|
||||||
|
-16.0,
|
||||||
|
16.0,
|
||||||
|
32.0
|
||||||
|
],
|
||||||
|
"Lyrics": [],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [],
|
||||||
|
"Palette": [
|
||||||
|
"#0B6623",
|
||||||
|
"#FF2D2D",
|
||||||
|
"#FFD700",
|
||||||
|
"#00BFFF",
|
||||||
|
"#9400D3",
|
||||||
|
"#00FF7F"
|
||||||
|
],
|
||||||
|
"GameOverText": "[NEXT YEAR -- DEFINITELY]"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "Durochka",
|
"Name": "Durochka",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 37.0,
|
"WindUpTimer": 37.0,
|
||||||
"Bpm": 129.9686,
|
"Bpm": 129.9686,
|
||||||
|
|
@ -968,6 +1033,7 @@
|
||||||
{
|
{
|
||||||
"Name": "GodMode",
|
"Name": "GodMode",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 40.38,
|
"WindUpTimer": 40.38,
|
||||||
"Bpm": 108.016876,
|
"Bpm": 108.016876,
|
||||||
|
|
@ -1022,6 +1088,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Gorgorod",
|
"Name": "Gorgorod",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 43.2,
|
"WindUpTimer": 43.2,
|
||||||
"Bpm": 90.0,
|
"Bpm": 90.0,
|
||||||
|
|
@ -1056,9 +1123,124 @@
|
||||||
],
|
],
|
||||||
"GameOverText": null
|
"GameOverText": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "HighLow",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
|
"Language": "English",
|
||||||
|
"WindUpTimer": 37.12,
|
||||||
|
"Bpm": 117.873367,
|
||||||
|
"Beats": 48,
|
||||||
|
"LoopOffset": 0,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 40.3,
|
||||||
|
"FileDurationLoop": 24.433,
|
||||||
|
"FileNameIntro": "HighLowIntro.ogg",
|
||||||
|
"FileNameLoop": "HighLowLoop.ogg",
|
||||||
|
"BeatsOffset": 0.0,
|
||||||
|
"FadeOutBeat": -1.5,
|
||||||
|
"FadeOutDuration": 1.5,
|
||||||
|
"ColorTransitionIn": 0.75,
|
||||||
|
"ColorTransitionOut": 0.25,
|
||||||
|
"ColorTransitionEasing": "OutExpo",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
-33.0,
|
||||||
|
39.0
|
||||||
|
],
|
||||||
|
"Lyrics": [],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
-2.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-1.0,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
-2.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-1.0,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"Palette": [
|
||||||
|
"#2E2E28",
|
||||||
|
"#DFA24D",
|
||||||
|
"#2E2E28",
|
||||||
|
"#DFA24D",
|
||||||
|
"#2E2E28",
|
||||||
|
"#DFA24D",
|
||||||
|
"#2E2E28",
|
||||||
|
"#DFA24D"
|
||||||
|
],
|
||||||
|
"GameOverText": "[LIFE SUPORT: NIRVANA]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "IkWilJe",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": "New Year",
|
||||||
|
"Language": "English",
|
||||||
|
"WindUpTimer": 43.03,
|
||||||
|
"Bpm": 82.11054,
|
||||||
|
"Beats": 54,
|
||||||
|
"LoopOffset": 0,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 63.815,
|
||||||
|
"FileDurationLoop": 39.459,
|
||||||
|
"FileNameIntro": "IkWilJeIntro.ogg",
|
||||||
|
"FileNameLoop": "IkWilJeLoop.ogg",
|
||||||
|
"BeatsOffset": 0.0,
|
||||||
|
"FadeOutBeat": -14.0,
|
||||||
|
"FadeOutDuration": 12.0,
|
||||||
|
"ColorTransitionIn": 0.01,
|
||||||
|
"ColorTransitionOut": 0.99,
|
||||||
|
"ColorTransitionEasing": "OutExpo",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
31.45
|
||||||
|
],
|
||||||
|
"Lyrics": [],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [],
|
||||||
|
"Palette": [
|
||||||
|
"#0B6623",
|
||||||
|
"#FF2D2D",
|
||||||
|
"#FFD700",
|
||||||
|
"#00BFFF",
|
||||||
|
"#9400D3",
|
||||||
|
"#00FF7F"
|
||||||
|
],
|
||||||
|
"GameOverText": "[NEXT YEAR -- DEFINITELY]"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "Kach",
|
"Name": "Kach",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 47.3,
|
"WindUpTimer": 47.3,
|
||||||
"Bpm": 153.6,
|
"Bpm": 153.6,
|
||||||
|
|
@ -1105,6 +1287,7 @@
|
||||||
{
|
{
|
||||||
"Name": "MoyaZhittya",
|
"Name": "MoyaZhittya",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 34.53,
|
"WindUpTimer": 34.53,
|
||||||
"Bpm": 120.0,
|
"Bpm": 120.0,
|
||||||
|
|
@ -1295,6 +1478,7 @@
|
||||||
{
|
{
|
||||||
"Name": "MuzikaGromche",
|
"Name": "MuzikaGromche",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 46.3,
|
"WindUpTimer": 46.3,
|
||||||
"Bpm": 129.729721,
|
"Bpm": 129.729721,
|
||||||
|
|
@ -1458,6 +1642,7 @@
|
||||||
{
|
{
|
||||||
"Name": "OnePartiyaUdar",
|
"Name": "OnePartiyaUdar",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Japanese",
|
"Language": "Japanese",
|
||||||
"WindUpTimer": 41.27,
|
"WindUpTimer": 41.27,
|
||||||
"Bpm": 130.06955,
|
"Bpm": 130.06955,
|
||||||
|
|
@ -1492,9 +1677,59 @@
|
||||||
],
|
],
|
||||||
"GameOverText": null
|
"GameOverText": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "Paarden",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": "New Year",
|
||||||
|
"Language": "Russian",
|
||||||
|
"WindUpTimer": 36.12,
|
||||||
|
"Bpm": 93.05482,
|
||||||
|
"Beats": 32,
|
||||||
|
"LoopOffset": 0,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 41.45,
|
||||||
|
"FileDurationLoop": 20.633,
|
||||||
|
"FileNameIntro": "PaardenIntro.ogg",
|
||||||
|
"FileNameLoop": "PaardenLoop.ogg",
|
||||||
|
"BeatsOffset": 0.0,
|
||||||
|
"FadeOutBeat": -4.0,
|
||||||
|
"FadeOutDuration": 4.0,
|
||||||
|
"ColorTransitionIn": 0.25,
|
||||||
|
"ColorTransitionOut": 0.4,
|
||||||
|
"ColorTransitionEasing": "OutCubic",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
31.5
|
||||||
|
],
|
||||||
|
"Lyrics": [],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [],
|
||||||
|
"Palette": [
|
||||||
|
"#F0FBFF",
|
||||||
|
"#9ED9FF",
|
||||||
|
"#0B95FF",
|
||||||
|
"#66C7FF",
|
||||||
|
"#CAE8FF",
|
||||||
|
"#3BB6FF"
|
||||||
|
],
|
||||||
|
"GameOverText": "[NEXT YEAR -- DEFINITELY]"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "Peretasovka",
|
"Name": "Peretasovka",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 39.68,
|
"WindUpTimer": 39.68,
|
||||||
"Bpm": 130.612244,
|
"Bpm": 130.612244,
|
||||||
|
|
@ -1530,9 +1765,302 @@
|
||||||
],
|
],
|
||||||
"GameOverText": null
|
"GameOverText": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "PickUpSticks1",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
|
"Language": "English",
|
||||||
|
"WindUpTimer": 38.5,
|
||||||
|
"Bpm": 99.89074,
|
||||||
|
"Beats": 64,
|
||||||
|
"LoopOffset": 0,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 43.413,
|
||||||
|
"FileDurationLoop": 38.442,
|
||||||
|
"FileNameIntro": "PickUpSticks1Intro.ogg",
|
||||||
|
"FileNameLoop": "PickUpSticksLoop.ogg",
|
||||||
|
"BeatsOffset": 0.2,
|
||||||
|
"FadeOutBeat": -2.0,
|
||||||
|
"FadeOutDuration": 2.0,
|
||||||
|
"ColorTransitionIn": 0.6,
|
||||||
|
"ColorTransitionOut": 0.3,
|
||||||
|
"ColorTransitionEasing": "InOutCubic",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
-36.0,
|
||||||
|
-4.0,
|
||||||
|
32.0
|
||||||
|
],
|
||||||
|
"Lyrics": [],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.5,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
32.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
34.0,
|
||||||
|
0.3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
40.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
23.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
28.0,
|
||||||
|
0.6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
31.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
34.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
38.0,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
52.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"Palette": [
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#D01760",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5"
|
||||||
|
],
|
||||||
|
"GameOverText": "[LOVE SUPPORT: OFFLINE]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "PickUpSticks2",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
|
"Language": "English",
|
||||||
|
"WindUpTimer": 38.47,
|
||||||
|
"Bpm": 99.89074,
|
||||||
|
"Beats": 64,
|
||||||
|
"LoopOffset": 0,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 43.404,
|
||||||
|
"FileDurationLoop": 38.442,
|
||||||
|
"FileNameIntro": "PickUpSticks2Intro.ogg",
|
||||||
|
"FileNameLoop": "PickUpSticksLoop.ogg",
|
||||||
|
"BeatsOffset": 0.2,
|
||||||
|
"FadeOutBeat": -2.0,
|
||||||
|
"FadeOutDuration": 2.0,
|
||||||
|
"ColorTransitionIn": 0.6,
|
||||||
|
"ColorTransitionOut": 0.3,
|
||||||
|
"ColorTransitionEasing": "InOutCubic",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
-36.0,
|
||||||
|
-4.0,
|
||||||
|
32.0
|
||||||
|
],
|
||||||
|
"Lyrics": [],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.5,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
32.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
34.0,
|
||||||
|
0.3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
40.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
23.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
28.0,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
31.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
34.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
38.0,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
52.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"Palette": [
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#D01760",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC933C",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#FC3C9D",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#CA71FC",
|
||||||
|
"#CA71FC",
|
||||||
|
"#D01760",
|
||||||
|
"#D01760",
|
||||||
|
"#EEA0A5",
|
||||||
|
"#EEA0A5"
|
||||||
|
],
|
||||||
|
"GameOverText": "[LOVE SUPPORT: OFFLINE]"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "PWNED",
|
"Name": "PWNED",
|
||||||
"IsExplicit": true,
|
"IsExplicit": true,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 39.73,
|
"WindUpTimer": 39.73,
|
||||||
"Bpm": 289.8113,
|
"Bpm": 289.8113,
|
||||||
|
|
@ -1702,19 +2230,19 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
84.0,
|
84.0,
|
||||||
"Instling min3r.exe\n33% [8====D ]\t\t\tresolving ur private IP\n\\"
|
"Instling min3r.exe\n34% [8====D ]\t\t\tresolving ur private IP\n\\"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
86.0,
|
86.0,
|
||||||
"Instling min3r.exe\n66% [8=========D ]\t\t\tresolving ur private IP\n|"
|
"Instling min3r.exe\n69% [8=========D ]\t\t\tresolving ur private IP\n| Trying... 127.0.0.1"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
88.0,
|
88.0,
|
||||||
"Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n/"
|
"Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n Trying... 127.0.0.1/"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
90.0,
|
90.0,
|
||||||
"Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n-"
|
"Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n- Trying... 127.0.0.1"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
92.0,
|
92.0,
|
||||||
|
|
@ -1899,6 +2427,7 @@
|
||||||
{
|
{
|
||||||
"Name": "ReelGoon",
|
"Name": "ReelGoon",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 45.15,
|
"WindUpTimer": 45.15,
|
||||||
"Bpm": 117.997726,
|
"Bpm": 117.997726,
|
||||||
|
|
@ -1958,6 +2487,7 @@
|
||||||
{
|
{
|
||||||
"Name": "RiseAndShine",
|
"Name": "RiseAndShine",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 59.87,
|
"WindUpTimer": 59.87,
|
||||||
"Bpm": 137.8815,
|
"Bpm": 137.8815,
|
||||||
|
|
@ -2014,6 +2544,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Song2",
|
"Name": "Song2",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 38.63,
|
"WindUpTimer": 38.63,
|
||||||
"Bpm": 50.0,
|
"Bpm": 50.0,
|
||||||
|
|
@ -2048,9 +2579,172 @@
|
||||||
],
|
],
|
||||||
"GameOverText": null
|
"GameOverText": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "TwoFastTuFurious",
|
||||||
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
|
"Language": "English",
|
||||||
|
"WindUpTimer": 36.08,
|
||||||
|
"Bpm": 130.034317,
|
||||||
|
"Beats": 96,
|
||||||
|
"LoopOffset": 48,
|
||||||
|
"Ext": "ogg",
|
||||||
|
"FileDurationIntro": 39.308,
|
||||||
|
"FileDurationLoop": 44.296,
|
||||||
|
"FileNameIntro": "TwoFastTuFuriousIntro.ogg",
|
||||||
|
"FileNameLoop": "TwoFastTuFuriousLoop.ogg",
|
||||||
|
"BeatsOffset": 0.0,
|
||||||
|
"FadeOutBeat": -54.0,
|
||||||
|
"FadeOutDuration": 6.0,
|
||||||
|
"ColorTransitionIn": 0.4,
|
||||||
|
"ColorTransitionOut": 0.6,
|
||||||
|
"ColorTransitionEasing": "InOutCubic",
|
||||||
|
"FlickerLightsTimeSeries": [
|
||||||
|
-80.0,
|
||||||
|
-14.0,
|
||||||
|
34.0,
|
||||||
|
82.0
|
||||||
|
],
|
||||||
|
"Lyrics": [
|
||||||
|
[
|
||||||
|
-126.0,
|
||||||
|
"Starting from here,\nlet's make a promise"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-110.0,
|
||||||
|
"You and me, let's just be honest"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-100.0,
|
||||||
|
"We're gonna run,\nnothing can stop us"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-89.0,
|
||||||
|
"Even the night,\nthat falls all around us"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-80.0,
|
||||||
|
"Soon there will be\nlaughter and voices"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-70.0,
|
||||||
|
"Beyond the clouds,\nover the mountains"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-62.0,
|
||||||
|
"We'll run away,\non roads that are empty"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-55.0,
|
||||||
|
"Lights from the airfield,\nshining upon you"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-48.0,
|
||||||
|
"Nothing can stop this"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-44.0,
|
||||||
|
"Nothing can stop this,\nnot now, I love you"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-40.0,
|
||||||
|
"They're not gonna get us"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-36.0,
|
||||||
|
"They're not gonna get us\nTHEY'RE NOT GONNA GET US"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"DrunknessLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
-48.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-47.75,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-42.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
48.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
48.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
54.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"CondensationLoopOffsetTimeSeries": [
|
||||||
|
[
|
||||||
|
-24.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-23.75,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-18.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
24.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
24.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
30.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
72.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
72.25,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
78.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"Palette": [
|
||||||
|
"#F0FBFF",
|
||||||
|
"#9ED9FF",
|
||||||
|
"#0B95FF",
|
||||||
|
"#66C7FF",
|
||||||
|
"#CAE8FF",
|
||||||
|
"#3BB6FF"
|
||||||
|
],
|
||||||
|
"GameOverText": "[ O NOES, THEY GOT US ]"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "VseVZale",
|
"Name": "VseVZale",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Russian",
|
"Language": "Russian",
|
||||||
"WindUpTimer": 38.28,
|
"WindUpTimer": 38.28,
|
||||||
"Bpm": 137.965729,
|
"Bpm": 137.965729,
|
||||||
|
|
@ -2195,6 +2889,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Whistle",
|
"Name": "Whistle",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "English",
|
"Language": "English",
|
||||||
"WindUpTimer": 41.27,
|
"WindUpTimer": 41.27,
|
||||||
"Bpm": 104.016182,
|
"Bpm": 104.016182,
|
||||||
|
|
@ -2345,6 +3040,7 @@
|
||||||
{
|
{
|
||||||
"Name": "Yalgaar",
|
"Name": "Yalgaar",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Hindi",
|
"Language": "Hindi",
|
||||||
"WindUpTimer": 52.17,
|
"WindUpTimer": 52.17,
|
||||||
"Bpm": 92.0157242,
|
"Bpm": 92.0157242,
|
||||||
|
|
@ -2382,6 +3078,7 @@
|
||||||
{
|
{
|
||||||
"Name": "ZmeiGorynich",
|
"Name": "ZmeiGorynich",
|
||||||
"IsExplicit": false,
|
"IsExplicit": false,
|
||||||
|
"Season": null,
|
||||||
"Language": "Korean",
|
"Language": "Korean",
|
||||||
"WindUpTimer": 46.13,
|
"WindUpTimer": 46.13,
|
||||||
"Bpm": 90.0014,
|
"Bpm": 90.0014,
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
|
@ -1,50 +0,0 @@
|
||||||
import sharp from 'sharp';
|
|
||||||
import toIco from 'png-to-ico';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const sourceIcon = path.resolve(__dirname, '../../icon.png');
|
|
||||||
const outputDir = path.resolve(__dirname, '../public');
|
|
||||||
|
|
||||||
async function generateIcons() {
|
|
||||||
await fs.mkdir(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
// Generate PNGs
|
|
||||||
const sizes = [32, 192, 256];
|
|
||||||
for (const size of sizes) {
|
|
||||||
const outputPath = path.join(outputDir, `icon-${size}.png`);
|
|
||||||
await sharp(sourceIcon)
|
|
||||||
.resize(size, size)
|
|
||||||
.toFile(outputPath);
|
|
||||||
console.log(`Generated ${outputPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate apple-touch-icon
|
|
||||||
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png');
|
|
||||||
await sharp(sourceIcon)
|
|
||||||
.resize(180, 180)
|
|
||||||
.toFile(appleIconPath);
|
|
||||||
console.log(`Generated ${appleIconPath}`);
|
|
||||||
|
|
||||||
// Generate favicon.ico
|
|
||||||
const icoSizes = [16, 24, 32, 48];
|
|
||||||
const buffers = await Promise.all(icoSizes.map(size =>
|
|
||||||
sharp(sourceIcon)
|
|
||||||
.resize(size, size)
|
|
||||||
.png()
|
|
||||||
.toBuffer()
|
|
||||||
));
|
|
||||||
const icoBuffer = await toIco(buffers);
|
|
||||||
const icoPath = path.join(outputDir, 'favicon.ico');
|
|
||||||
await fs.writeFile(icoPath, icoBuffer);
|
|
||||||
console.log(`Generated ${icoPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
generateIcons().catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import toIco from 'png-to-ico'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const sourceIcon = path.resolve(__dirname, '../../icon.png')
|
||||||
|
const outputDir = path.resolve(__dirname, '../public')
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
await fs.mkdir(outputDir, { recursive: true })
|
||||||
|
|
||||||
|
// Generate PNGs
|
||||||
|
const sizes = [32, 192, 256]
|
||||||
|
for (const size of sizes) {
|
||||||
|
const outputPath = path.join(outputDir, `icon-${size}.png`)
|
||||||
|
await sharp(sourceIcon)
|
||||||
|
.resize(size, size)
|
||||||
|
.toFile(outputPath)
|
||||||
|
console.log(`Generated ${outputPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate apple-touch-icon
|
||||||
|
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png')
|
||||||
|
await sharp(sourceIcon)
|
||||||
|
.resize(180, 180)
|
||||||
|
.toFile(appleIconPath)
|
||||||
|
console.log(`Generated ${appleIconPath}`)
|
||||||
|
|
||||||
|
// Generate favicon.ico
|
||||||
|
const icoSizes = [16, 24, 32, 48]
|
||||||
|
const buffers = await Promise.all(icoSizes.map(size =>
|
||||||
|
sharp(sourceIcon)
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toBuffer(),
|
||||||
|
))
|
||||||
|
const icoBuffer = await toIco(buffers)
|
||||||
|
const icoPath = path.join(outputDir, 'favicon.ico')
|
||||||
|
await fs.writeFile(icoPath, icoBuffer)
|
||||||
|
console.log(`Generated ${icoPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIcons().catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GlobalHeader from '@/components/GlobalHeader.vue';
|
import GlobalHeader from '@/global-header/GlobalHeader.vue'
|
||||||
|
import { useGlobalHeaderStore } from './global-header/GlobalHeaderStore'
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = 'MuzikaGromche'
|
||||||
|
|
||||||
|
const globalHeaderStore = useGlobalHeaderStore()
|
||||||
|
globalHeaderStore.defaultTitle = DEFAULT_TITLE
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -9,30 +9,32 @@
|
||||||
- exposes getPosition() to read current playback time relative to intro start
|
- exposes getPosition() to read current playback time relative to intro start
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type AudioTrack, useWrapTime, wrapTimeFn } from "@/lib/AudioTrack";
|
import type { ConfigurableWindow } from '@vueuse/core'
|
||||||
import type { Seconds } from "@/lib/units";
|
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||||
|
import type { AudioTrack } from '@/lib/AudioTrack'
|
||||||
|
import type { Seconds } from '@/lib/units'
|
||||||
import {
|
import {
|
||||||
type ConfigurableWindow,
|
|
||||||
tryOnScopeDispose,
|
tryOnScopeDispose,
|
||||||
useRafFn,
|
useRafFn,
|
||||||
useThrottleFn,
|
useThrottleFn,
|
||||||
watchImmediate,
|
watchImmediate,
|
||||||
} from "@vueuse/core";
|
} from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
type MaybeRefOrGetter,
|
|
||||||
type Ref,
|
|
||||||
shallowRef,
|
shallowRef,
|
||||||
toValue,
|
toValue,
|
||||||
watch,
|
watch,
|
||||||
} from "vue";
|
} from 'vue'
|
||||||
|
import { useWrapTime, wrapTimeFn } from '@/lib/AudioTrack'
|
||||||
|
|
||||||
export const VOLUME_MAX: number = 1.5;
|
export const VOLUME_MAX: number = 1.5
|
||||||
|
|
||||||
interface PlayerHandle {
|
interface PlayerHandle {
|
||||||
/**
|
/**
|
||||||
* The `stop()` method schedules a sound to cease playback at the specified time.
|
* The `stop()` method schedules a sound to cease playback at the specified time.
|
||||||
*/
|
*/
|
||||||
stop: (when?: Seconds) => void;
|
stop: (when?: Seconds) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioTrackBuffersHandle extends PlayerHandle {
|
interface AudioTrackBuffersHandle extends PlayerHandle {
|
||||||
|
|
@ -40,7 +42,7 @@ interface AudioTrackBuffersHandle extends PlayerHandle {
|
||||||
* Time in AudioContext coordinate system of a moment which lines up with the start of the intro audio buffer.
|
* Time in AudioContext coordinate system of a moment which lines up with the start of the intro audio buffer.
|
||||||
* If the startPosition was greater than zero, this time is already in the past when the function returns.
|
* If the startPosition was greater than zero, this time is already in the past when the function returns.
|
||||||
*/
|
*/
|
||||||
readonly introStartTime: Seconds;
|
readonly introStartTime: Seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,83 +59,86 @@ function playAudioTrackBuffers(
|
||||||
*/
|
*/
|
||||||
startPosition: Seconds = 0,
|
startPosition: Seconds = 0,
|
||||||
): AudioTrackBuffersHandle {
|
): AudioTrackBuffersHandle {
|
||||||
const now = audioCtx.currentTime;
|
const now = audioCtx.currentTime
|
||||||
|
|
||||||
const introBuffer = audioTrack.loadedIntro!;
|
const introBuffer = audioTrack.loadedIntro!
|
||||||
const loopBuffer = audioTrack.loadedLoop!;
|
const loopBuffer = audioTrack.loadedLoop!
|
||||||
|
|
||||||
const introDuration = introBuffer.duration;
|
const introDuration = introBuffer.duration
|
||||||
const loopDuration = loopBuffer.duration;
|
const loopDuration = loopBuffer.duration
|
||||||
|
|
||||||
const wrapper = wrapTimeFn(audioTrack);
|
const wrapper = wrapTimeFn(audioTrack)
|
||||||
startPosition = wrapper(startPosition);
|
startPosition = wrapper(startPosition)
|
||||||
|
|
||||||
let currentIntro: AudioBufferSourceNode | null;
|
let currentIntro: AudioBufferSourceNode | null
|
||||||
let currentLoop: AudioBufferSourceNode | null;
|
let currentLoop: AudioBufferSourceNode | null
|
||||||
let introStartTime: Seconds;
|
let introStartTime: Seconds
|
||||||
|
|
||||||
// figure out where to start
|
// figure out where to start
|
||||||
if (startPosition < introDuration) {
|
if (startPosition < introDuration) {
|
||||||
// start intro with offset, schedule loop after remaining intro time
|
// start intro with offset, schedule loop after remaining intro time
|
||||||
const introOffset = startPosition;
|
const introOffset = startPosition
|
||||||
const timeUntilLoop = introDuration - introOffset;
|
const timeUntilLoop = introDuration - introOffset
|
||||||
|
|
||||||
const introNode = audioCtx.createBufferSource();
|
const introNode = audioCtx.createBufferSource()
|
||||||
introNode.buffer = introBuffer;
|
introNode.buffer = introBuffer
|
||||||
introNode.connect(destinationNode);
|
introNode.connect(destinationNode)
|
||||||
introNode.start(now, introOffset);
|
introNode.start(now, introOffset)
|
||||||
|
|
||||||
const loopNode = audioCtx.createBufferSource();
|
const loopNode = audioCtx.createBufferSource()
|
||||||
loopNode.buffer = loopBuffer;
|
loopNode.buffer = loopBuffer
|
||||||
loopNode.loop = true;
|
loopNode.loop = true
|
||||||
loopNode.connect(destinationNode);
|
loopNode.connect(destinationNode)
|
||||||
loopNode.start(now + timeUntilLoop, 0);
|
loopNode.start(now + timeUntilLoop, 0)
|
||||||
|
|
||||||
currentIntro = introNode;
|
currentIntro = introNode
|
||||||
currentLoop = loopNode;
|
currentLoop = loopNode
|
||||||
introStartTime = now - startPosition;
|
introStartTime = now - startPosition
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// start directly in loop with proper offset into loop
|
// start directly in loop with proper offset into loop
|
||||||
const loopOffset = (startPosition - introDuration) % loopDuration;
|
const loopOffset = (startPosition - introDuration) % loopDuration
|
||||||
const loopNode = audioCtx.createBufferSource();
|
const loopNode = audioCtx.createBufferSource()
|
||||||
loopNode.buffer = loopBuffer;
|
loopNode.buffer = loopBuffer
|
||||||
loopNode.loop = true;
|
loopNode.loop = true
|
||||||
loopNode.connect(destinationNode);
|
loopNode.connect(destinationNode)
|
||||||
loopNode.start(now, loopOffset);
|
loopNode.start(now, loopOffset)
|
||||||
|
|
||||||
currentIntro = null;
|
currentIntro = null
|
||||||
currentLoop = loopNode;
|
currentLoop = loopNode
|
||||||
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
|
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
|
||||||
// introStartTime = now - introDuration - loopOffset;
|
// introStartTime = now - introDuration - loopOffset;
|
||||||
introStartTime = now - startPosition;
|
introStartTime = now - startPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop(when?: Seconds) {
|
function stop(when?: Seconds) {
|
||||||
try {
|
try {
|
||||||
currentIntro?.stop(when);
|
currentIntro?.stop(when)
|
||||||
} catch (e) {
|
}
|
||||||
|
catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
currentLoop?.stop(when);
|
currentLoop?.stop(when)
|
||||||
} catch (e) {
|
}
|
||||||
|
catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
currentIntro = null;
|
currentIntro = null
|
||||||
currentLoop = null;
|
currentLoop = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { introStartTime, stop };
|
return { introStartTime, stop }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
|
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
|
||||||
playerResult: Omit<T, "stop">;
|
playerResult: Omit<T, 'stop'>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 25 ms for fade-in/fade-out
|
* 25 ms for fade-in/fade-out
|
||||||
*/
|
*/
|
||||||
const DEFAULT_FADE_DURATION = 0.025;
|
const DEFAULT_FADE_DURATION = 0.025
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap the given player function with a Gain node. Applies fade in effect on start and fade out on stop.
|
* Wrap the given player function with a Gain node. Applies fade in effect on start and fade out on stop.
|
||||||
|
|
@ -149,34 +154,34 @@ function playWithFadeInOut<T extends PlayerHandle>(
|
||||||
*/
|
*/
|
||||||
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
|
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
|
||||||
): PlayWithFadeInOut<T> {
|
): PlayWithFadeInOut<T> {
|
||||||
const GAIN_MIN = 0.0001;
|
const GAIN_MIN = 0.0001
|
||||||
const GAIN_MAX = 1.0;
|
const GAIN_MAX = 1.0
|
||||||
|
|
||||||
const fadeGain = audioCtx.createGain();
|
const fadeGain = audioCtx.createGain()
|
||||||
fadeGain.connect(destinationNode);
|
fadeGain.connect(destinationNode)
|
||||||
fadeGain.gain.value = GAIN_MIN;
|
fadeGain.gain.value = GAIN_MIN
|
||||||
|
|
||||||
const playerHandle = player(fadeGain);
|
const playerHandle = player(fadeGain)
|
||||||
|
|
||||||
// fade in
|
// fade in
|
||||||
const now = audioCtx.currentTime;
|
const now = audioCtx.currentTime
|
||||||
const fadeEnd = now + fadeDuration;
|
const fadeEnd = now + fadeDuration
|
||||||
fadeGain.gain.setValueAtTime(GAIN_MIN, now);
|
fadeGain.gain.setValueAtTime(GAIN_MIN, now)
|
||||||
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd);
|
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd)
|
||||||
|
|
||||||
// TODO: setTimeout to actually stop after `when`?
|
// TODO: setTimeout to actually stop after `when`?
|
||||||
function stop(_when?: Seconds) {
|
function stop(_when?: Seconds) {
|
||||||
// fade out
|
// fade out
|
||||||
const now = audioCtx.currentTime;
|
const now = audioCtx.currentTime
|
||||||
const fadeEnd = now + fadeDuration;
|
const fadeEnd = now + fadeDuration
|
||||||
fadeGain.gain.cancelScheduledValues(now);
|
fadeGain.gain.cancelScheduledValues(now)
|
||||||
fadeGain.gain.setValueAtTime(GAIN_MAX, now);
|
fadeGain.gain.setValueAtTime(GAIN_MAX, now)
|
||||||
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd);
|
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd)
|
||||||
|
|
||||||
playerHandle.stop(fadeEnd);
|
playerHandle.stop(fadeEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { playerResult: playerHandle, stop };
|
return { playerResult: playerHandle, stop }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -186,19 +191,19 @@ export interface PlaybackState {
|
||||||
/**
|
/**
|
||||||
* Readonly reference to whether audio is currently playing.
|
* Readonly reference to whether audio is currently playing.
|
||||||
*/
|
*/
|
||||||
readonly isPlaying: Readonly<Ref<boolean>>;
|
readonly isPlaying: Readonly<Ref<boolean>>
|
||||||
/**
|
/**
|
||||||
* Readonly reference to the last remembered start-of-playback position.
|
* Readonly reference to the last remembered start-of-playback position.
|
||||||
*
|
*
|
||||||
* Will only update if stop(rememberPosition=true) or seek() is called.
|
* Will only update if stop(rememberPosition=true) or seek() is called.
|
||||||
*/
|
*/
|
||||||
readonly startPosition: Readonly<Ref<Seconds>>;
|
readonly startPosition: Readonly<Ref<Seconds>>
|
||||||
/**
|
/**
|
||||||
* Returns current playback position in seconds based on AudioContext time.
|
* Returns current playback position in seconds based on AudioContext time.
|
||||||
*
|
*
|
||||||
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
|
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
|
||||||
*/
|
*/
|
||||||
getCurrentPosition(): Seconds;
|
getCurrentPosition: () => Seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StopOptions {
|
export interface StopOptions {
|
||||||
|
|
@ -207,7 +212,7 @@ export interface StopOptions {
|
||||||
*
|
*
|
||||||
* Defaults to false.
|
* Defaults to false.
|
||||||
*/
|
*/
|
||||||
rememberPosition?: boolean;
|
rememberPosition?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeekOptions {
|
export interface SeekOptions {
|
||||||
|
|
@ -216,7 +221,7 @@ export interface SeekOptions {
|
||||||
*
|
*
|
||||||
* Defaults to false.
|
* Defaults to false.
|
||||||
*/
|
*/
|
||||||
scrub?: boolean;
|
scrub?: boolean
|
||||||
// TODO: optionally keep playing after seeking?
|
// TODO: optionally keep playing after seeking?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,24 +232,24 @@ export interface PlayerControls {
|
||||||
/**
|
/**
|
||||||
* Start playing audio buffers from the last remembered position.
|
* Start playing audio buffers from the last remembered position.
|
||||||
*/
|
*/
|
||||||
play: () => void;
|
play: () => void
|
||||||
/**
|
/**
|
||||||
* Stop playing audio buffers.
|
* Stop playing audio buffers.
|
||||||
*
|
*
|
||||||
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
|
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
|
||||||
*/
|
*/
|
||||||
stop: (options?: StopOptions) => void;
|
stop: (options?: StopOptions) => void
|
||||||
/**
|
/**
|
||||||
* Seek to given position in seconds.
|
* Seek to given position in seconds.
|
||||||
*
|
*
|
||||||
* - Stop the playback.
|
* - Stop the playback.
|
||||||
* - If scrub is requested, plays a short sample at that position.
|
* - If scrub is requested, plays a short sample at that position.
|
||||||
*/
|
*/
|
||||||
seek: (position: Seconds, options?: SeekOptions) => void;
|
seek: (position: Seconds, options?: SeekOptions) => void
|
||||||
/**
|
/**
|
||||||
* Properties relates to the state of playback.
|
* Properties relates to the state of playback.
|
||||||
*/
|
*/
|
||||||
readonly playback: PlaybackState;
|
readonly playback: PlaybackState
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
|
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
|
||||||
|
|
@ -255,58 +260,44 @@ function reusableAudioBuffersTrackPlayer(
|
||||||
destinationNode: AudioNode,
|
destinationNode: AudioNode,
|
||||||
audioTrack: AudioTrack,
|
audioTrack: AudioTrack,
|
||||||
): ReusableAudioBuffersTrackPlayer {
|
): ReusableAudioBuffersTrackPlayer {
|
||||||
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null;
|
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null
|
||||||
const isPlaying = shallowRef(false);
|
const isPlaying = shallowRef(false)
|
||||||
const wrapper = wrapTimeFn(audioTrack);
|
const wrapper = wrapTimeFn(audioTrack)
|
||||||
const startPosition = useWrapTime(audioTrack, 0);
|
const startPosition = useWrapTime(audioTrack, 0)
|
||||||
|
|
||||||
function play() {
|
function play() {
|
||||||
if (currentHandle) {
|
if (currentHandle) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
currentHandle = playWithFadeInOut(
|
currentHandle = playWithFadeInOut(
|
||||||
audioCtx,
|
audioCtx,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
(destinationNode) =>
|
destinationNode =>
|
||||||
playAudioTrackBuffers(
|
playAudioTrackBuffers(
|
||||||
audioCtx,
|
audioCtx,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
startPosition.value,
|
startPosition.value,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
isPlaying.value = true;
|
isPlaying.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop(options?: { rememberPosition?: boolean }) {
|
function stop(options?: { rememberPosition?: boolean }) {
|
||||||
const {
|
const {
|
||||||
rememberPosition = false,
|
rememberPosition = false,
|
||||||
} = options ?? {};
|
} = options ?? {}
|
||||||
|
|
||||||
if (currentHandle) {
|
if (currentHandle) {
|
||||||
isPlaying.value = false;
|
isPlaying.value = false
|
||||||
|
|
||||||
if (rememberPosition) {
|
if (rememberPosition) {
|
||||||
startPosition.value = getCurrentPosition();
|
startPosition.value = getCurrentPosition()
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop and discard current handle
|
// stop and discard current handle
|
||||||
currentHandle.stop();
|
currentHandle.stop()
|
||||||
currentHandle = null;
|
currentHandle = null
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seek(seekPosition: Seconds, options?: SeekOptions) {
|
|
||||||
const {
|
|
||||||
scrub = false,
|
|
||||||
} = options ?? {};
|
|
||||||
|
|
||||||
stop({ rememberPosition: false });
|
|
||||||
|
|
||||||
startPosition.value = seekPosition;
|
|
||||||
|
|
||||||
if (scrub) {
|
|
||||||
doThrottledScrub();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +308,7 @@ function reusableAudioBuffersTrackPlayer(
|
||||||
const scrubHandle = playWithFadeInOut(
|
const scrubHandle = playWithFadeInOut(
|
||||||
audioCtx,
|
audioCtx,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
(destinationNode) =>
|
destinationNode =>
|
||||||
playAudioTrackBuffers(
|
playAudioTrackBuffers(
|
||||||
audioCtx,
|
audioCtx,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
|
|
@ -325,21 +316,35 @@ function reusableAudioBuffersTrackPlayer(
|
||||||
startPosition.value,
|
startPosition.value,
|
||||||
),
|
),
|
||||||
0.01, // short fade of 10 ms
|
0.01, // short fade of 10 ms
|
||||||
);
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrubHandle.stop(0.01);
|
scrubHandle.stop(0.01)
|
||||||
}, 80); // stop after N ms
|
}, 80) // stop after N ms
|
||||||
}, 80);
|
}, 80)
|
||||||
|
|
||||||
|
function seek(seekPosition: Seconds, options?: SeekOptions) {
|
||||||
|
const {
|
||||||
|
scrub = false,
|
||||||
|
} = options ?? {}
|
||||||
|
|
||||||
|
stop({ rememberPosition: false })
|
||||||
|
|
||||||
|
startPosition.value = seekPosition
|
||||||
|
|
||||||
|
if (scrub) {
|
||||||
|
doThrottledScrub()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentPosition(): Seconds {
|
function getCurrentPosition(): Seconds {
|
||||||
if (!currentHandle) {
|
if (!currentHandle) {
|
||||||
return startPosition.value;
|
return startPosition.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = audioCtx.currentTime -
|
const elapsed = audioCtx.currentTime
|
||||||
currentHandle.playerResult.introStartTime;
|
- currentHandle.playerResult.introStartTime
|
||||||
|
|
||||||
return wrapper(elapsed);
|
return wrapper(elapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -351,183 +356,199 @@ function reusableAudioBuffersTrackPlayer(
|
||||||
startPosition,
|
startPosition,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LivePlaybackPositionOptions extends ConfigurableWindow {
|
interface LivePlaybackPositionOptions extends ConfigurableWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LivePlaybackPositionReturn {
|
interface LivePlaybackPositionReturn {
|
||||||
stop: () => void;
|
stop: () => void
|
||||||
position: Readonly<Ref<Seconds>>;
|
position: Readonly<Ref<Seconds>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLivePlaybackPosition(
|
export function useLivePlaybackPosition(
|
||||||
playback: MaybeRefOrGetter<PlaybackState | null>,
|
playback: MaybeRefOrGetter<PlaybackState | null>,
|
||||||
options?: LivePlaybackPositionOptions,
|
options?: LivePlaybackPositionOptions,
|
||||||
): LivePlaybackPositionReturn {
|
): LivePlaybackPositionReturn {
|
||||||
const cleanups: Function[] = [];
|
const cleanups: (() => void)[] = []
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
cleanups.forEach((fn) => fn());
|
cleanups.forEach(fn => fn())
|
||||||
cleanups.length = 0;
|
cleanups.length = 0
|
||||||
};
|
}
|
||||||
|
|
||||||
const getPosition = () => {
|
const getPosition = () => {
|
||||||
return toValue(playback)?.getCurrentPosition() ?? 0;
|
return toValue(playback)?.getCurrentPosition() ?? 0
|
||||||
};
|
}
|
||||||
|
|
||||||
const position = shallowRef<Seconds>(getPosition());
|
const position = shallowRef<Seconds>(getPosition())
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
position.value = getPosition();
|
position.value = getPosition()
|
||||||
};
|
}
|
||||||
|
|
||||||
const raf = useRafFn(() => {
|
const raf = useRafFn(() => {
|
||||||
updatePosition();
|
updatePosition()
|
||||||
}, {
|
}, {
|
||||||
...options,
|
...options,
|
||||||
immediate: false,
|
immediate: false,
|
||||||
once: false,
|
once: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const stopWatch = watchImmediate(() => [
|
const stopWatch = watchImmediate(() => [
|
||||||
toValue(playback),
|
toValue(playback),
|
||||||
], ([playback]) => {
|
], ([playback]) => {
|
||||||
cleanup();
|
cleanup()
|
||||||
|
|
||||||
updatePosition();
|
updatePosition()
|
||||||
|
|
||||||
if (!playback) return;
|
if (!playback) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
|
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
raf.resume();
|
raf.resume()
|
||||||
} else {
|
|
||||||
raf.pause();
|
|
||||||
updatePosition();
|
|
||||||
}
|
}
|
||||||
}));
|
else {
|
||||||
|
raf.pause()
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
cleanups.push(watch(playback.startPosition, () => {
|
cleanups.push(watch(playback.startPosition, () => {
|
||||||
raf.pause();
|
raf.pause()
|
||||||
updatePosition();
|
updatePosition()
|
||||||
if (playback.isPlaying.value) {
|
if (playback.isPlaying.value) {
|
||||||
raf.resume();
|
raf.resume()
|
||||||
}
|
}
|
||||||
}));
|
}))
|
||||||
|
|
||||||
cleanups.push(() => raf.pause());
|
cleanups.push(() => raf.pause())
|
||||||
});
|
})
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopWatch();
|
stopWatch()
|
||||||
cleanup();
|
cleanup()
|
||||||
};
|
}
|
||||||
|
|
||||||
tryOnScopeDispose(cleanup);
|
tryOnScopeDispose(cleanup)
|
||||||
|
|
||||||
return { stop, position };
|
return { stop, position }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePlayStop(
|
export function togglePlayStop(
|
||||||
player: PlayerControls | null,
|
player: PlayerControls | null,
|
||||||
options?: StopOptions,
|
options?: StopOptions,
|
||||||
) {
|
) {
|
||||||
if (!player) return;
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (player.playback.isPlaying.value) {
|
if (player.playback.isPlaying.value) {
|
||||||
player.stop(options);
|
player.stop(options)
|
||||||
} else {
|
}
|
||||||
player.play();
|
else {
|
||||||
|
player.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AudioEngine {
|
class AudioEngine {
|
||||||
audioCtx: AudioContext | null = null;
|
audioCtx: AudioContext | null = null
|
||||||
masterGain: GainNode | null = null; // controlled by UI volume slider
|
masterGain: GainNode | null = null // controlled by UI volume slider
|
||||||
// fadeGain: GainNode | null = null; // tiny fade to avoid clicks
|
// fadeGain: GainNode | null = null; // tiny fade to avoid clicks
|
||||||
|
|
||||||
// cache of decoded buffers by URL
|
// cache of decoded buffers by URL
|
||||||
bufferCache = new Map<string, AudioBuffer>();
|
bufferCache = new Map<string, AudioBuffer>()
|
||||||
|
|
||||||
private _player: Ref<PlayerControls | null> = shallowRef(null);
|
private _player: Ref<PlayerControls | null> = shallowRef(null)
|
||||||
// readonly player: Readonly<Ref<PlayerControls | null>> = this._player;
|
// readonly player: Readonly<Ref<PlayerControls | null>> = this._player;
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
|
fadeDuration = 0.025 // 25 ms for fade-in/fade-out
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (this.audioCtx) return;
|
if (this.audioCtx) {
|
||||||
this.audioCtx =
|
return
|
||||||
new (window.AudioContext || (window as any).webkitAudioContext)();
|
}
|
||||||
|
this.audioCtx
|
||||||
|
= new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
|
||||||
this.masterGain = this.audioCtx.createGain();
|
this.masterGain = this.audioCtx.createGain()
|
||||||
|
|
||||||
// routing: sources -> fadeGain -> masterGain -> destination
|
// routing: sources -> fadeGain -> masterGain -> destination
|
||||||
this.masterGain.connect(this.audioCtx.destination);
|
this.masterGain.connect(this.audioCtx.destination)
|
||||||
// default full volume
|
// default full volume
|
||||||
this.masterGain.gain.value = 1;
|
this.masterGain.gain.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
this.stopPlayer();
|
this.stopPlayer()
|
||||||
this.audioCtx?.close();
|
this.audioCtx?.close()
|
||||||
this.audioCtx = null;
|
this.audioCtx = null
|
||||||
this.masterGain = null;
|
this.masterGain = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchAudioBuffer(
|
async fetchAudioBuffer(
|
||||||
url: string,
|
url: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<AudioBuffer> {
|
): Promise<AudioBuffer> {
|
||||||
this.init();
|
this.init()
|
||||||
if (this.bufferCache.has(url)) return this.bufferCache.get(url)!;
|
if (this.bufferCache.has(url)) {
|
||||||
const res = await fetch(url, { signal });
|
return this.bufferCache.get(url)!
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Network error ${res.status} when fetching ${url}`);
|
|
||||||
}
|
}
|
||||||
const arrayBuffer = await res.arrayBuffer();
|
const res = await fetch(url, { signal })
|
||||||
const audioBuffer = await this.audioCtx!.decodeAudioData(arrayBuffer);
|
if (!res.ok) {
|
||||||
this.bufferCache.set(url, audioBuffer);
|
throw new Error(`Network error ${res.status} when fetching ${url}`)
|
||||||
return audioBuffer;
|
}
|
||||||
|
const arrayBuffer = await res.arrayBuffer()
|
||||||
|
const audioBuffer = await this.audioCtx!.decodeAudioData(arrayBuffer)
|
||||||
|
this.bufferCache.set(url, audioBuffer)
|
||||||
|
return audioBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// set UI volume 0..VOLUME_MAX
|
// set UI volume 0..VOLUME_MAX
|
||||||
setVolume(value: number) {
|
setVolume(value: number) {
|
||||||
this.init();
|
this.init()
|
||||||
if (!this.masterGain || !this.audioCtx) return;
|
if (!this.masterGain || !this.audioCtx) {
|
||||||
const now = this.audioCtx.currentTime;
|
return
|
||||||
|
}
|
||||||
|
const now = this.audioCtx.currentTime
|
||||||
// small linear ramp to avoid jumps
|
// small linear ramp to avoid jumps
|
||||||
this.masterGain.gain.cancelScheduledValues(now);
|
this.masterGain.gain.cancelScheduledValues(now)
|
||||||
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
|
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now)
|
||||||
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05);
|
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05)
|
||||||
}
|
}
|
||||||
|
|
||||||
initPlayer(
|
initPlayer(
|
||||||
audioTrack: AudioTrack,
|
audioTrack: AudioTrack,
|
||||||
): PlayerControls | null {
|
): PlayerControls | null {
|
||||||
this.init();
|
this.init()
|
||||||
if (!this.audioCtx || !this.masterGain) return null;
|
if (!this.audioCtx || !this.masterGain) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
this.stopPlayer();
|
this.stopPlayer()
|
||||||
|
|
||||||
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) return null;
|
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const player = reusableAudioBuffersTrackPlayer(
|
const player = reusableAudioBuffersTrackPlayer(
|
||||||
this.audioCtx,
|
this.audioCtx,
|
||||||
this.masterGain,
|
this.masterGain,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
);
|
)
|
||||||
this._player.value = player;
|
this._player.value = player
|
||||||
return player;
|
return player
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopPlayer() {
|
private stopPlayer() {
|
||||||
if (this._player.value) {
|
if (this._player.value) {
|
||||||
this._player.value.stop();
|
this._player.value.stop()
|
||||||
this._player.value = null;
|
this._player.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioEngine = new AudioEngine();
|
const audioEngine = new AudioEngine()
|
||||||
export default audioEngine;
|
export default audioEngine
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,50 @@
|
||||||
import type { Px } from "@/lib/units";
|
import type { Fn } from '@vueuse/core'
|
||||||
import { useWeakCache } from "@/lib/useWeakCache";
|
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||||
import { type Fn, tryOnScopeDispose, watchImmediate } from "@vueuse/core";
|
import type { Px } from '@/lib/units'
|
||||||
import type { MaybeRefOrGetter, Ref } from "vue";
|
import { tryOnScopeDispose, watchImmediate } from '@vueuse/core'
|
||||||
import { computed, shallowRef, toValue, triggerRef } from "vue";
|
import { computed, shallowRef, toValue, triggerRef } from 'vue'
|
||||||
|
import { useWeakCache } from '@/lib/useWeakCache'
|
||||||
|
|
||||||
// Result of async computation
|
// Result of async computation
|
||||||
interface UseWaveform {
|
interface UseWaveform {
|
||||||
readonly isDone: Readonly<Ref<boolean>>;
|
readonly isDone: Readonly<Ref<boolean>>
|
||||||
readonly peaks: Readonly<Ref<Float32Array>>;
|
readonly peaks: Readonly<Ref<Float32Array>>
|
||||||
stop: () => void;
|
stop: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WaveformComputation {
|
interface WaveformComputation {
|
||||||
readonly isDone: Readonly<Ref<boolean>>;
|
readonly isDone: Readonly<Ref<boolean>>
|
||||||
readonly peaks: Readonly<Ref<Float32Array>>;
|
readonly peaks: Readonly<Ref<Float32Array>>
|
||||||
/** Start or continue asynchronous computation. */
|
/** Start or continue asynchronous computation. */
|
||||||
run: () => void;
|
run: () => void
|
||||||
/** Stops any ongoing asynchronous computation. */
|
/** Stops any ongoing asynchronous computation. */
|
||||||
stop: () => void;
|
stop: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
|
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
)
|
||||||
|
|
||||||
const WAVEFORM_MIN_WIDTH = 10;
|
const WAVEFORM_MIN_WIDTH = 10
|
||||||
|
|
||||||
const emptyComputation: WaveformComputation = {
|
const emptyComputation: WaveformComputation = {
|
||||||
isDone: shallowRef(false),
|
isDone: shallowRef(false),
|
||||||
peaks: shallowRef(new Float32Array(0)),
|
peaks: shallowRef(new Float32Array(0)),
|
||||||
run() {},
|
run() {},
|
||||||
stop() {},
|
stop() {},
|
||||||
};
|
}
|
||||||
|
|
||||||
export function useWaveform(
|
export function useWaveform(
|
||||||
buffer: MaybeRefOrGetter<AudioBuffer>,
|
buffer: MaybeRefOrGetter<AudioBuffer>,
|
||||||
width: MaybeRefOrGetter<Px>,
|
width: MaybeRefOrGetter<Px>,
|
||||||
): UseWaveform {
|
): UseWaveform {
|
||||||
const cleanups: Fn[] = [];
|
const cleanups: Fn[] = []
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
cleanups.forEach((fn) => fn());
|
cleanups.forEach(fn => fn())
|
||||||
cleanups.length = 0;
|
cleanups.length = 0
|
||||||
};
|
}
|
||||||
|
|
||||||
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation);
|
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation)
|
||||||
|
|
||||||
const stopWatch = watchImmediate(
|
const stopWatch = watchImmediate(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -52,140 +53,138 @@ export function useWaveform(
|
||||||
toValue(width),
|
toValue(width),
|
||||||
] as const,
|
] as const,
|
||||||
([b, w]) => {
|
([b, w]) => {
|
||||||
cleanup();
|
cleanup()
|
||||||
|
|
||||||
const map = waveformsCache.getOrNew(b);
|
const map = waveformsCache.getOrNew(b)
|
||||||
|
|
||||||
if (w < WAVEFORM_MIN_WIDTH) {
|
if (w < WAVEFORM_MIN_WIDTH) {
|
||||||
compRef.value = emptyComputation;
|
compRef.value = emptyComputation
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let comp = map.get(w);
|
let comp = map.get(w)
|
||||||
if (!comp) {
|
if (!comp) {
|
||||||
comp = useWaveformComputation(b, w);
|
comp = useWaveformComputation(b, w)
|
||||||
map.set(w, comp);
|
map.set(w, comp)
|
||||||
}
|
}
|
||||||
compRef.value = comp;
|
compRef.value = comp
|
||||||
comp.run();
|
comp.run()
|
||||||
cleanups.push(() => {
|
cleanups.push(() => {
|
||||||
compRef.value = emptyComputation;
|
compRef.value = emptyComputation
|
||||||
comp.stop();
|
comp.stop()
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopWatch();
|
stopWatch()
|
||||||
cleanup();
|
cleanup()
|
||||||
};
|
}
|
||||||
|
|
||||||
tryOnScopeDispose(stop);
|
tryOnScopeDispose(stop)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDone: computed(() => compRef.value.isDone.value),
|
isDone: computed(() => compRef.value.isDone.value),
|
||||||
peaks: computed(() => compRef.value.peaks.value),
|
peaks: computed(() => compRef.value.peaks.value),
|
||||||
stop,
|
stop,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useWaveformComputation = (
|
function useWaveformComputation(buffer: AudioBuffer, width: Px): WaveformComputation {
|
||||||
buffer: AudioBuffer,
|
|
||||||
width: Px,
|
|
||||||
): WaveformComputation => {
|
|
||||||
// How many times run() has been called without stop().
|
// How many times run() has been called without stop().
|
||||||
// This whole computation should not stop until there is at least one user out there.
|
// This whole computation should not stop until there is at least one user out there.
|
||||||
let users = 0;
|
let users = 0
|
||||||
|
|
||||||
// How many pixels of `width` have been processed so far
|
// How many pixels of `width` have been processed so far
|
||||||
let progress = 0;
|
let progress = 0
|
||||||
|
|
||||||
|
let timeoutID: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
// Waveform data, length shall be equal to the requested width
|
// Waveform data, length shall be equal to the requested width
|
||||||
const waveform = new Float32Array(width);
|
const waveform = new Float32Array(width)
|
||||||
|
|
||||||
const isDone = shallowRef(false);
|
const isDone = shallowRef(false)
|
||||||
const peaks = shallowRef(waveform);
|
const peaks = shallowRef(waveform)
|
||||||
|
|
||||||
const nChannels = buffer.numberOfChannels;
|
const nChannels = buffer.numberOfChannels
|
||||||
|
|
||||||
const samplesPerPx = buffer.length / width;
|
const samplesPerPx = buffer.length / width
|
||||||
const blocksPerChannel: Float32Array<ArrayBuffer>[] = [];
|
const blocksPerChannel: Float32Array<ArrayBuffer>[] = []
|
||||||
for (let channel = 0; channel < nChannels; channel++) {
|
for (let channel = 0; channel < nChannels; channel++) {
|
||||||
blocksPerChannel[channel] = new Float32Array(Math.ceil(samplesPerPx));
|
blocksPerChannel[channel] = new Float32Array(Math.ceil(samplesPerPx))
|
||||||
}
|
}
|
||||||
|
|
||||||
const areWeDoneYet = () => progress >= width;
|
const areWeDoneYet = () => progress >= width
|
||||||
|
|
||||||
function stepBlock() {
|
function stepBlock() {
|
||||||
const blockStart = Math.floor(progress * samplesPerPx);
|
const blockStart = Math.floor(progress * samplesPerPx)
|
||||||
const blockEnd = Math.floor((progress + 1) * samplesPerPx);
|
const blockEnd = Math.floor((progress + 1) * samplesPerPx)
|
||||||
const blockSize = blockEnd - blockStart;
|
const blockSize = blockEnd - blockStart
|
||||||
|
|
||||||
for (let channel = 0; channel < nChannels; channel++) {
|
for (let channel = 0; channel < nChannels; channel++) {
|
||||||
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart);
|
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
waveform[progress] = compressBlock(blocksPerChannel, blockSize);
|
waveform[progress] = compressBlock(blocksPerChannel, blockSize)
|
||||||
progress += 1;
|
progress += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepBatchOfBlocks() {
|
function stepBatchOfBlocks() {
|
||||||
// run blocks for up to ~10ms to keep UI responsive
|
// run blocks for up to ~10ms to keep UI responsive
|
||||||
const start = performance.now();
|
const start = performance.now()
|
||||||
const progressStart = progress;
|
const progressStart = progress
|
||||||
while (!areWeDoneYet()) {
|
while (!areWeDoneYet()) {
|
||||||
stepBlock();
|
stepBlock()
|
||||||
if (performance.now() - start >= 10 || progress - progressStart > 100) {
|
if (performance.now() - start >= 10 || progress - progressStart > 100) {
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerRef(peaks);
|
triggerRef(peaks)
|
||||||
// triggerRef may as well not trigger refs
|
// triggerRef may as well not trigger refs
|
||||||
// https://github.com/vuejs/core/issues/9579
|
// https://github.com/vuejs/core/issues/9579
|
||||||
// Combined with a throttled drawing function,
|
// Combined with a throttled drawing function,
|
||||||
// this is a slightly better-than-worse workaround.
|
// this is a slightly better-than-worse workaround.
|
||||||
peaks.value = new Float32Array(0);
|
peaks.value = new Float32Array(0)
|
||||||
peaks.value = waveform;
|
peaks.value = waveform
|
||||||
|
|
||||||
if (areWeDoneYet()) {
|
if (areWeDoneYet()) {
|
||||||
isDone.value = true;
|
isDone.value = true
|
||||||
timeoutID = NaN;
|
timeoutID = undefined
|
||||||
} else {
|
}
|
||||||
timeoutID = setTimeout(stepBatchOfBlocks, 1);
|
else {
|
||||||
|
timeoutID = setTimeout(stepBatchOfBlocks, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeoutID: number = NaN;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDone,
|
isDone,
|
||||||
peaks,
|
peaks,
|
||||||
run() {
|
run() {
|
||||||
users += 1;
|
users += 1
|
||||||
|
|
||||||
if (Number.isNaN(timeoutID) && users === 1) {
|
if (timeoutID === undefined && users === 1) {
|
||||||
timeoutID = setTimeout(stepBatchOfBlocks, 0);
|
timeoutID = setTimeout(stepBatchOfBlocks, 0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
users -= 1;
|
users -= 1
|
||||||
|
|
||||||
if (!Number.isNaN(timeoutID) && users === 0) {
|
if (!timeoutID === undefined && users === 0) {
|
||||||
window.clearTimeout(timeoutID);
|
window.clearTimeout(timeoutID)
|
||||||
timeoutID = NaN;
|
timeoutID = undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function compressBlock(channels: Float32Array[], blockSize: number): number {
|
function compressBlock(channels: Float32Array[], blockSize: number): number {
|
||||||
let peak = 0.0;
|
let peak = 0.0
|
||||||
|
|
||||||
for (let i = 0; i < blockSize; i++) {
|
for (let i = 0; i < blockSize; i++) {
|
||||||
for (let channel = 0; channel < channels.length; channel++) {
|
for (let channel = 0; channel < channels.length; channel++) {
|
||||||
peak = Math.max(peak, Math.abs(channels[channel]![i]!));
|
peak = Math.max(peak, Math.abs(channels[channel]![i]!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return peak;
|
return peak
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,22 @@ const {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
title: string,
|
title: string
|
||||||
description: string | null,
|
description: string | null
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:h-full tw:flex tw:flex-col">
|
<div class="tw:h-full tw:flex tw:flex-col">
|
||||||
<div class="tw:w-full tw:max-w-2xl tw:self-center">
|
<div class="tw:w-full tw:max-w-2xl tw:self-center">
|
||||||
<h1 class="tw:text-4xl tw:p-8">{{ title }}</h1>
|
<h1 class="tw:text-4xl tw:p-8">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
<p class="tw:p-4">
|
<p class="tw:p-4">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTrackStore } from '@/store/TrackStore';
|
import { storeToRefs } from 'pinia'
|
||||||
import { storeToRefs } from 'pinia';
|
import { useTrackStore } from '@/store/TrackStore'
|
||||||
// import OpenInNew from '@material-design-icons/svg/outlined/open_in_new.svg?url&inline';
|
|
||||||
// import OpenInNew2 from '@material-design-icons/svg/outlined/open_in_new.svg';
|
|
||||||
|
|
||||||
const trackStore = useTrackStore();
|
const trackStore = useTrackStore()
|
||||||
const { version } = storeToRefs(trackStore);
|
const { version } = storeToRefs(trackStore)
|
||||||
|
|
||||||
const product = "MuzikaGromche";
|
|
||||||
const productLink = "https://thunderstore.io/c/lethal-company/p/Ratijas/MuzikaGromche/";
|
|
||||||
const year = 2025;
|
|
||||||
const author = "Ratijas";
|
|
||||||
const authorLink = "https://ratijas.me";
|
|
||||||
|
|
||||||
|
const product = 'MuzikaGromche'
|
||||||
|
const productLink = 'https://thunderstore.io/c/lethal-company/p/Ratijas/MuzikaGromche/'
|
||||||
|
const year = '2025–2026'
|
||||||
|
const author = 'Ratijas'
|
||||||
|
const authorLink = 'https://ratijas.me'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<footer>
|
<footer>
|
||||||
<!-- TODO: A bug/omission somewhere in Vite/Rolldown/SVGO prevents Vite/SVGO plugin config from injecting { "fill": "currentColor" } into ?inline imported SVG. -->
|
|
||||||
<div
|
<div
|
||||||
class="tw:py-2 tw:px-4 tw:gap-2 tw:flex tw:flex-row tw:max-sm:flex-col tw:flex-wrap tw:items-center tw:justify-center toolbar-background"
|
class="tw:py-2 tw:px-4 tw:gap-2 tw:flex tw:flex-row tw:max-sm:flex-col tw:flex-wrap tw:items-center tw:justify-center toolbar-background"
|
||||||
style="border-top: var(--view-separator-border);">
|
style="border-top: var(--view-separator-border);"
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
<span>
|
<span>
|
||||||
<a :href="productLink" target="_blank" rel="nofollow">{{ product }}</a>
|
<a :href="productLink" target="_blank" rel="nofollow">{{ product }}</a>
|
||||||
|
|
@ -41,6 +39,7 @@ const authorLink = "https://ratijas.me";
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.separator {
|
.separator {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TransitionPresets, useTransition, watchDebounced } from "@vueuse/core";
|
import { TransitionPresets, useTransition, watchDebounced } from '@vueuse/core'
|
||||||
import { computed, shallowRef, useId } from "vue";
|
import { computed, shallowRef, useId } from 'vue'
|
||||||
import ScreenTransition from "./ScreenTransition.vue";
|
import ScreenTransition from './ScreenTransition.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
visible,
|
visible,
|
||||||
message = "Loading…",
|
message = 'Loading…',
|
||||||
progress = undefined,
|
progress = undefined,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
visible: boolean,
|
visible: boolean
|
||||||
message?: string,
|
message?: string
|
||||||
// loading progress, range 0..1
|
// loading progress, range 0..1
|
||||||
progress?: number | undefined,
|
progress?: number | undefined
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
// CSS transition on width does not work in Firefox
|
// CSS transition on width does not work in Firefox
|
||||||
const zeroProgress = computed(() => progress ?? 0);
|
const zeroProgress = computed(() => progress ?? 0)
|
||||||
const easedProgress = useTransition(zeroProgress, {
|
const easedProgress = useTransition(zeroProgress, {
|
||||||
duration: 400,
|
duration: 400,
|
||||||
easing: TransitionPresets.easeInOutQuad,
|
easing: TransitionPresets.easeInOutQuad,
|
||||||
});
|
})
|
||||||
|
|
||||||
const progressId = useId();
|
const progressId = useId()
|
||||||
// Let the progress animation finish before cutting it off of updates
|
// Let the progress animation finish before cutting it off of updates
|
||||||
const actuallyVisible = shallowRef(visible);
|
const actuallyVisible = shallowRef(visible)
|
||||||
watchDebounced(() => visible, () => {
|
watchDebounced(() => visible, () => {
|
||||||
actuallyVisible.value = visible;
|
actuallyVisible.value = visible
|
||||||
}, { debounce: 600 })
|
}, { debounce: 600 })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ScreenTransition :visible="actuallyVisible">
|
<ScreenTransition :visible="actuallyVisible">
|
||||||
<div class="tw:h-full tw:flex tw:flex-col tw:gap-8 tw:items-center tw:justify-center tw:text-2xl">
|
<div class="tw:h-full tw:flex tw:flex-col tw:gap-8 tw:items-center tw:justify-center tw:text-2xl">
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean,
|
visible: boolean
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition>
|
<Transition>
|
||||||
<div v-if="visible" class="tw:h-full tw:overflow-hidden tw:isolate" style="background-color: var(--main-background-color);">
|
<div v-if="visible" class="tw:h-full tw:overflow-hidden tw:isolate" style="background-color: var(--main-background-color);">
|
||||||
<div class="tw:h-full">
|
<div class="tw:h-full">
|
||||||
<slot></slot>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@property --screen-transition-duration {
|
@property --screen-transition-duration {
|
||||||
syntax: "<time>";
|
syntax: "<time>";
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Search from '@material-design-icons/svg/outlined/search.svg';
|
import Clear from '@material-design-icons/svg/outlined/clear.svg'
|
||||||
import Clear from '@material-design-icons/svg/outlined/clear.svg';
|
import Search from '@material-design-icons/svg/outlined/search.svg'
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
placeholder = "Search…",
|
placeholder = 'Search…',
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
placeholder?: string,
|
placeholder?: string
|
||||||
}>();
|
}>()
|
||||||
const model = defineModel({ type: String, required: true });
|
const model = defineModel({ type: String, required: true })
|
||||||
|
|
||||||
const clearDisabled = computed(() => model.value === "");
|
const clearDisabled = computed(() => model.value === '')
|
||||||
function clear() {
|
function clear() {
|
||||||
model.value = "";
|
model.value = ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:flex tw:gap-1 tw:p-0.5 tw:px-1 tw:place-items-center tw:justify-center tw:text-xl input-text">
|
<div class="tw:flex tw:gap-1 tw:p-0.5 tw:px-1 tw:place-items-center tw:justify-center tw:text-xl input-text">
|
||||||
<Search class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" style="color: #929292;" />
|
<Search class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" style="color: #929292;" />
|
||||||
<input type="text" role="searchbox" v-model="model" :placeholder class="tw:flex-1 tw:min-w-0" />
|
<input v-model="model" type="text" role="searchbox" :placeholder class="tw:flex-1 tw:min-w-0">
|
||||||
<button type="button" role="button" name="clear" aria-roledescription="Clear search field" tabindex="-1" title="Clear"
|
<button
|
||||||
@click="clear" :disabled="clearDisabled" class="button">
|
type="button" role="button" name="clear" aria-roledescription="Clear search field" tabindex="-1" title="Clear"
|
||||||
|
:disabled="clearDisabled" class="button" @click="clear"
|
||||||
|
>
|
||||||
<Clear class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" />
|
<Clear class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.button {
|
.button {
|
||||||
color: #929292;
|
color: #929292;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import { useTrackStore } from '@/store/TrackStore';
|
import { useTrackStore } from '@/store/TrackStore'
|
||||||
|
|
||||||
const trackStore = useTrackStore();
|
const trackStore = useTrackStore()
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Timestamp from '@/components/timeline/Timestamp.vue';
|
import type { AudioTrack } from '@/lib/AudioTrack'
|
||||||
import { LANGUAGES, type AudioTrack } from '@/lib/AudioTrack';
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg'
|
||||||
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
import { computed } from 'vue'
|
||||||
import { computed } from 'vue';
|
import Timestamp from '@/components/timeline/Timestamp.vue'
|
||||||
|
import { LANGUAGES } from '@/lib/AudioTrack'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
track,
|
track,
|
||||||
edit,
|
edit,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: AudioTrack,
|
track: AudioTrack
|
||||||
edit: boolean,
|
edit: boolean
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const beatSeconds = computed(() => track.loadedLoop!.duration / track.Beats);
|
const beatSeconds = computed(() => track.loadedLoop!.duration / track.Beats)
|
||||||
const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
|
const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="info-wrapper">
|
<div class="info-wrapper">
|
||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
|
|
@ -29,7 +31,9 @@ const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
|
||||||
<td>Language:</td>
|
<td>Language:</td>
|
||||||
<td>
|
<td>
|
||||||
<select v-if="edit" v-model="track.Language">
|
<select v-if="edit" v-model="track.Language">
|
||||||
<option v-for="language in LANGUAGES" :value="language">{{ language }}</option>
|
<option v-for="language in LANGUAGES" :key="language" :value="language">
|
||||||
|
{{ language }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ track.Language }}
|
{{ track.Language }}
|
||||||
|
|
@ -38,8 +42,9 @@ const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Is explicit:</td>
|
<td>Is explicit:</td>
|
||||||
<td><div style="display: flex; gap: 8px;">
|
<td>
|
||||||
<input type="checkbox" id="isExplicit" :checked="track.IsExplicit" :disabled="!edit" />
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<input id="isExplicit" type="checkbox" :checked="track.IsExplicit" :disabled="!edit">
|
||||||
<label for="isExplicit" style="flex: 1;">
|
<label for="isExplicit" style="flex: 1;">
|
||||||
<div title="Warning: Explicit Content" class="tw:flex-none">
|
<div title="Warning: Explicit Content" class="tw:flex-none">
|
||||||
<Explicit class="tw:w-6 tw:h-6 tw:fill-current" />
|
<Explicit class="tw:w-6 tw:h-6 tw:fill-current" />
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,21 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ScrollablePanel from "@/components/library/panel/ScrollablePanel.vue";
|
import Construction from '@material-design-icons/svg/round/construction.svg'
|
||||||
import Construction from "@material-design-icons/svg/round/construction.svg";
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, shallowRef } from "vue";
|
import { shallowRef } from 'vue'
|
||||||
import AudioTrack from "./views/AudioTrack.vue";
|
import ScrollablePanel from '@/components/library/panel/ScrollablePanel.vue'
|
||||||
import { useTimelineStore } from "@/store/TimelineStore";
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import { storeToRefs } from "pinia";
|
import AudioTrack from './views/AudioTrack.vue'
|
||||||
|
|
||||||
// TODO: use selection (inspector?) manager
|
// TODO: use selection (inspector?) manager
|
||||||
const selection = shallowRef<object | null>({});
|
const selection = shallowRef<object | null>({})
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore()
|
||||||
const { audioTrack, tracksMap } = storeToRefs(timeline);
|
const { audioTrack } = storeToRefs(timeline)
|
||||||
|
|
||||||
const introClip = computed(() => tracksMap.value.intro.clips[0]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- inspector panel -->
|
<!-- inspector panel -->
|
||||||
<ScrollablePanel class="tw:flex-none tw:min-w-80 tw:max-w-80 tw:border-s">
|
<ScrollablePanel class="tw:flex-none tw:min-w-80 tw:max-w-80 tw:border-s">
|
||||||
|
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<h3 class="tw:flex tw:flex-row tw:items-center tw:gap-2 tw:px-4 tw:py-1 tw:select-none">
|
<h3 class="tw:flex tw:flex-row tw:items-center tw:gap-2 tw:px-4 tw:py-1 tw:select-none">
|
||||||
<Construction class="tw:fill-current tw:h-5 tw:w-5 toolbar-icon-shadow" />
|
<Construction class="tw:fill-current tw:h-5 tw:w-5 toolbar-icon-shadow" />
|
||||||
|
|
@ -29,22 +26,20 @@ const introClip = computed(() => tracksMap.value.intro.clips[0]);
|
||||||
<template #default>
|
<template #default>
|
||||||
<!-- inspector content -->
|
<!-- inspector content -->
|
||||||
<div class="tw:px-4 tw:h-full tw:flex tw:flex-col" @click="selection = selection ? {} : {}">
|
<div class="tw:px-4 tw:h-full tw:flex tw:flex-col" @click="selection = selection ? {} : {}">
|
||||||
|
|
||||||
<!-- nothing to inspect -->
|
<!-- nothing to inspect -->
|
||||||
<div v-if="!selection"
|
<div
|
||||||
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none">
|
v-if="!selection"
|
||||||
|
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none"
|
||||||
|
>
|
||||||
Nothing to inspect
|
Nothing to inspect
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- inspect selection -->
|
<!-- inspect selection -->
|
||||||
<div v-else class="tw:flex-1 tw:flex tw:flex-col tw:gap-4 tw:py-2 tw:text-xs">
|
<div v-else class="tw:flex-1 tw:flex tw:flex-col tw:gap-4 tw:py-2 tw:text-xs">
|
||||||
<AudioTrack v-if="audioTrack" :audioTrack />
|
<AudioTrack v-if="audioTrack" :audio-track />
|
||||||
<!-- <Clip v-if="introClip" :clip="introClip" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Control } from "@/components/inspector/controls";
|
import type { Control } from '@/components/inspector/controls'
|
||||||
import { computed } from "vue";
|
import { computed } from 'vue'
|
||||||
import { getComponentFor } from "./impl";
|
import { getComponentFor } from './impl'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: Control;
|
control: Control
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const view = computed(() => getComponentFor(control));
|
|
||||||
|
|
||||||
|
const view = computed(() => getComponentFor(control))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Controls } from "../controls";
|
import type { Controls } from '../controls'
|
||||||
import Control from "./Control.vue";
|
import Control from './Control.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
controls,
|
controls,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
controls: Controls;
|
controls: Controls
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
|
<div
|
||||||
style="grid-template-columns: 80px minmax(0, 1fr);">
|
class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
|
||||||
|
style="grid-template-columns: 80px minmax(0, 1fr);"
|
||||||
|
>
|
||||||
<Control v-for="control in controls" :key="control.key" :control />
|
<Control v-for="control in controls" :key="control.key" :control />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,31 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BaseNamedControl } from "@/components/inspector/controls";
|
import type { BaseNamedControl } from '@/components/inspector/controls'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
id,
|
id,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: BaseNamedControl;
|
control: BaseNamedControl
|
||||||
/**
|
/**
|
||||||
* Input ID for an associated label.
|
* Input ID for an associated label.
|
||||||
*/
|
*/
|
||||||
id?: string;
|
id?: string
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
// TODO: reset function
|
// TODO: reset function
|
||||||
function reset(event: MouseEvent) {
|
function reset(event: MouseEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- label -->
|
<!-- label -->
|
||||||
<label :for="id" class="tw:text-right control-label" :class="{ 'control-label__disabled': control.disabled }"
|
<label
|
||||||
@dblclick="reset">
|
:for="id"
|
||||||
|
class="tw:text-right control-label"
|
||||||
|
:class="{ 'control-label__disabled': control.disabled }"
|
||||||
|
@dblclick="reset"
|
||||||
|
>
|
||||||
{{ control.name }}
|
{{ control.name }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ButtonControl } from "@/components/inspector/controls";
|
import type { ButtonControl } from '@/components/inspector/controls'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: ButtonControl;
|
control: ButtonControl
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control>
|
<BaseNamedControlView :control>
|
||||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-start">
|
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-start">
|
||||||
<button type="button" @click="control.action" :disabled="control.disabled" class="control-button">
|
<button
|
||||||
<component v-if="control.icon" :is="control.icon" class="control-button__icon" />
|
type="button"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
class="control-button"
|
||||||
|
@click="control.action"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="control.icon"
|
||||||
|
v-if="control.icon"
|
||||||
|
class="control-button__icon"
|
||||||
|
/>
|
||||||
<span class="control-button__text">{{ control.text }}</span>
|
<span class="control-button__text">{{ control.text }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CheckboxControl } from "@/components/inspector/controls";
|
import type { CheckboxControl } from '@/components/inspector/controls'
|
||||||
import { useId } from "vue";
|
import { useId } from 'vue'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: CheckboxControl;
|
control: CheckboxControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const id = useId();
|
const id = useId()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control :id>
|
<BaseNamedControlView :id :control>
|
||||||
<label class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
|
<label
|
||||||
:class="{ 'control-label__disabled': control.disabled }">
|
class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
|
||||||
<input type="checkbox" v-model="control.ref" :id :disabled="control.disabled" />
|
:class="{ 'control-label__disabled': control.disabled }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id
|
||||||
|
v-model="control.ref"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
>
|
||||||
<component :is="control.icon" class="tw:flex-none tw:w-4 tw:h-4 tw:fill-current tw:self-center" />
|
<component :is="control.icon" class="tw:flex-none tw:w-4 tw:h-4 tw:fill-current tw:self-center" />
|
||||||
<span v-if="control.label">
|
<span v-if="control.label">
|
||||||
{{ control.label }}
|
{{ control.label }}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropDownControl } from "@/components/inspector/controls";
|
import type { DropDownControl } from '@/components/inspector/controls'
|
||||||
import { useId } from "vue";
|
import { useId } from 'vue'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: DropDownControl;
|
control: DropDownControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const id = useId();
|
const id = useId()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control :id>
|
<BaseNamedControlView :id :control>
|
||||||
<div>
|
<div>
|
||||||
<select :id v-model="control.ref.value" :disabled="control.disabled"
|
<select
|
||||||
class="tw:w-full tw:max-w-full control-select">
|
:id
|
||||||
<option v-for="option in control.options" :value="option">
|
v-model="control.ref.value"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
class="tw:w-full tw:max-w-full control-select"
|
||||||
|
>
|
||||||
|
<option v-for="option in control.options" :key="option" :value="option">
|
||||||
{{ option }}
|
{{ option }}
|
||||||
<!-- and very long text what gonna happen -->
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HrControl } from "@/components/inspector/controls";
|
import type { HrControl } from '@/components/inspector/controls'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
control: HrControl;
|
control: HrControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:col-span-full tw:py-2">
|
<div class="tw:col-span-full tw:py-2">
|
||||||
<hr class="tw:w-full" style="color: var(--inspector-section-separator-color);" />
|
<hr class="tw:w-full" style="color: var(--inspector-section-separator-color);">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BaseControl } from "@/components/inspector/controls";
|
import type { BaseControl } from '@/components/inspector/controls'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
control: BaseControl;
|
control: BaseControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:col-span-full" >Not Implemented: {{ control.kind }}</div>
|
<div class="tw:col-span-full">
|
||||||
|
Not Implemented: {{ control.kind }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,31 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NumberControl } from "@/components/inspector/controls";
|
import type { NumberControl } from '@/components/inspector/controls'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import { useId } from 'vue'
|
||||||
import { useId } from "vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: NumberControl;
|
control: NumberControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const id = useId();
|
const id = useId()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control :id>
|
<BaseNamedControlView :id :control>
|
||||||
<div>
|
<div>
|
||||||
<input :id type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
<input
|
||||||
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-20" />
|
:id
|
||||||
|
v-model.number="control.ref.value"
|
||||||
|
type="number"
|
||||||
|
:min="control.min"
|
||||||
|
:max="control.max"
|
||||||
|
:step="0.01"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
:readonly="control.readonly"
|
||||||
|
class="input-text input-number tw:w-20"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</BaseNamedControlView>
|
</BaseNamedControlView>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,40 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RangeControl } from "@/components/inspector/controls";
|
import type { RangeControl } from '@/components/inspector/controls'
|
||||||
import Slider from "@/components/library/Slider.vue";
|
import { useId } from 'vue'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import Slider from '@/components/library/Slider.vue'
|
||||||
import { useId } from "vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: RangeControl;
|
control: RangeControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const id = useId();
|
const id = useId()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control :id>
|
<BaseNamedControlView :id :control>
|
||||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
|
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
|
||||||
<Slider :id v-model.number="control.ref.value"
|
<Slider
|
||||||
@update:model-value="(value) => control.ref.value = value ?? control.default" :min="control.min"
|
:id v-model.number="control.ref.value"
|
||||||
:max="control.max" :step="0.01" :defaultValue="0" :disabled="control.disabled || control.readonly"
|
:min="control.min" :max="control.max"
|
||||||
class="tw:flex-1 tw:self-end" />
|
:step="0.01"
|
||||||
<input type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
:default-value="0"
|
||||||
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-14" />
|
:disabled="control.disabled || control.readonly"
|
||||||
|
class="tw:flex-1 tw:self-end"
|
||||||
|
@update:model-value="(value) => control.ref.value = value ?? control.default"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model.number="control.ref.value"
|
||||||
|
type="number"
|
||||||
|
:min="control.min"
|
||||||
|
:max="control.max"
|
||||||
|
:step="0.01"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
:readonly="control.readonly"
|
||||||
|
class="input-text input-number tw:w-14"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</BaseNamedControlView>
|
</BaseNamedControlView>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TextControl } from "@/components/inspector/controls";
|
import type { TextControl } from '@/components/inspector/controls'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: TextControl;
|
control: TextControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control>
|
<BaseNamedControlView :control>
|
||||||
<div>
|
<div>
|
||||||
<textarea rows="4" v-model="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
<textarea
|
||||||
class="tw:w-full tw:max-w-full tw:block tw:resize-none input-text" :class="{ 'tw:font-mono': control.monospace }"
|
v-model="control.ref.value"
|
||||||
spellcheck="false" />
|
rows="4"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
:readonly="control.readonly"
|
||||||
|
class="tw:w-full tw:max-w-full tw:block tw:resize-none input-text"
|
||||||
|
:class="{ 'tw:font-mono': control.monospace }"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BaseNamedControlView>
|
</BaseNamedControlView>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TextControl } from "@/components/inspector/controls";
|
import type { TextControl } from '@/components/inspector/controls'
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
control: TextControl;
|
control: TextControl
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseNamedControlView :control>
|
<BaseNamedControlView :control>
|
||||||
<div class="input-text">
|
<div class="input-text">
|
||||||
<input type="text" :value="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
<input
|
||||||
class="tw:w-full tw:max-w-full" />
|
type="text"
|
||||||
|
:value="control.ref.value"
|
||||||
|
:disabled="control.disabled"
|
||||||
|
:readonly="control.readonly"
|
||||||
|
class="tw:w-full tw:max-w-full"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</BaseNamedControlView>
|
</BaseNamedControlView>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import type { Component } from "vue";
|
import type { Component } from 'vue'
|
||||||
import type { Control } from "..";
|
import type { Control } from '..'
|
||||||
import ButtonControlView from "./ButtonControlView.vue";
|
import ButtonControlView from './ButtonControlView.vue'
|
||||||
import CheckboxControlView from "./CheckboxControlView.vue";
|
import CheckboxControlView from './CheckboxControlView.vue'
|
||||||
import DropDownControlView from "./DropDownControlView.vue";
|
import DropDownControlView from './DropDownControlView.vue'
|
||||||
import HrControlView from "./HrControlView.vue";
|
import HrControlView from './HrControlView.vue'
|
||||||
import NotImplementedControlView from "./NotImplementedControlView.vue";
|
import NotImplementedControlView from './NotImplementedControlView.vue'
|
||||||
import NumberControlView from "./NumberControlView.vue";
|
import NumberControlView from './NumberControlView.vue'
|
||||||
import RangeControlView from "./RangeControlView.vue";
|
import RangeControlView from './RangeControlView.vue'
|
||||||
import TextAreaControlView from "./TextAreaControlView.vue";
|
import TextAreaControlView from './TextAreaControlView.vue'
|
||||||
import TextControlView from "./TextControlView.vue";
|
import TextControlView from './TextControlView.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping from `control.kind` to the component that renders it.
|
* Mapping from `control.kind` to the component that renders it.
|
||||||
*/
|
*/
|
||||||
const viewMap: Record<Control["kind"], Component> = {
|
const viewMap: Record<Control['kind'], Component> = {
|
||||||
hr: HrControlView,
|
hr: HrControlView,
|
||||||
text: TextControlView,
|
text: TextControlView,
|
||||||
textarea: TextAreaControlView,
|
textarea: TextAreaControlView,
|
||||||
|
|
@ -22,7 +22,7 @@ const viewMap: Record<Control["kind"], Component> = {
|
||||||
checkbox: CheckboxControlView,
|
checkbox: CheckboxControlView,
|
||||||
dropdown: DropDownControlView,
|
dropdown: DropDownControlView,
|
||||||
button: ButtonControlView,
|
button: ButtonControlView,
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map `control.kind` to the component that renders it.
|
* Map `control.kind` to the component that renders it.
|
||||||
|
|
@ -30,5 +30,5 @@ const viewMap: Record<Control["kind"], Component> = {
|
||||||
* @returns A Component that expects a single `control` property of the same kind as the one passing into this function.
|
* @returns A Component that expects a single `control` property of the same kind as the one passing into this function.
|
||||||
*/
|
*/
|
||||||
export function getComponentFor<T extends Control>(control: T): Component {
|
export function getComponentFor<T extends Control>(control: T): Component {
|
||||||
return viewMap[control.kind] ?? NotImplementedControlView;
|
return viewMap[control.kind] ?? NotImplementedControlView
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,99 @@
|
||||||
import type { Component, Ref } from "vue";
|
import type { Component, Ref } from 'vue'
|
||||||
|
|
||||||
export interface BaseControl {
|
export interface BaseControl {
|
||||||
/**
|
/**
|
||||||
* Discriminator for different types of controls.
|
* Discriminator for different types of controls.
|
||||||
*/
|
*/
|
||||||
kind: string;
|
kind: string
|
||||||
/**
|
/**
|
||||||
* Unique key of the control.
|
* Unique key of the control.
|
||||||
*/
|
*/
|
||||||
key: string;
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HrControl extends BaseControl {
|
export interface HrControl extends BaseControl {
|
||||||
kind: "hr";
|
kind: 'hr'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseNamedControl extends BaseControl {
|
export interface BaseNamedControl extends BaseControl {
|
||||||
/**
|
/**
|
||||||
* Control's name, displayed on the left of the control view itself. Double click it to reset.
|
* Control's name, displayed on the left of the control view itself. Double click it to reset.
|
||||||
*/
|
*/
|
||||||
name: string;
|
name: string
|
||||||
/**
|
/**
|
||||||
* An Icon component to display inline with a label.
|
* An Icon component to display inline with a label.
|
||||||
*/
|
*/
|
||||||
icon?: string | Component;
|
icon?: string | Component
|
||||||
/**
|
/**
|
||||||
* Whether the control is disabled as a whole. Dims the label and implies readonly.
|
* Whether the control is disabled as a whole. Dims the label and implies readonly.
|
||||||
*/
|
*/
|
||||||
disabled?: boolean;
|
disabled?: boolean
|
||||||
/**
|
/**
|
||||||
* Whether the value should be allowed to change.
|
* Whether the value should be allowed to change.
|
||||||
*/
|
*/
|
||||||
readonly?: boolean;
|
readonly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseTextControl extends BaseNamedControl {
|
export interface BaseTextControl extends BaseNamedControl {
|
||||||
ref: Ref<string>;
|
ref: Ref<string>
|
||||||
/** Whether to use monospace font. Defaults to false. */
|
/** Whether to use monospace font. Defaults to false. */
|
||||||
monospace?: boolean;
|
monospace?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextControl extends BaseTextControl {
|
export interface TextControl extends BaseTextControl {
|
||||||
kind: "text";
|
kind: 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextAreaControl extends BaseTextControl {
|
export interface TextAreaControl extends BaseTextControl {
|
||||||
kind: "textarea";
|
kind: 'textarea'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseNumberControl extends BaseNamedControl {
|
export interface BaseNumberControl extends BaseNamedControl {
|
||||||
min: number;
|
min: number
|
||||||
max: number;
|
max: number
|
||||||
default: number;
|
default: number
|
||||||
ref: Ref<number>;
|
ref: Ref<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A range slider accompanied by an input field. */
|
/** A range slider accompanied by an input field. */
|
||||||
export interface RangeControl extends BaseNumberControl {
|
export interface RangeControl extends BaseNumberControl {
|
||||||
kind: "range";
|
kind: 'range'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A text input field for a number. */
|
/** A text input field for a number. */
|
||||||
export interface NumberControl extends BaseNumberControl {
|
export interface NumberControl extends BaseNumberControl {
|
||||||
kind: "number";
|
kind: 'number'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckboxControl extends BaseNamedControl {
|
export interface CheckboxControl extends BaseNamedControl {
|
||||||
kind: "checkbox";
|
kind: 'checkbox'
|
||||||
/** Optional additional label for the checkbox input */
|
/** Optional additional label for the checkbox input */
|
||||||
label?: string;
|
label?: string
|
||||||
ref: Ref<boolean>;
|
ref: Ref<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DropDownControl extends BaseNamedControl {
|
export interface DropDownControl extends BaseNamedControl {
|
||||||
kind: "dropdown";
|
kind: 'dropdown'
|
||||||
options: readonly string[];
|
options: readonly string[]
|
||||||
ref: Ref<string>;
|
ref: Ref<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ButtonControl extends BaseNamedControl {
|
export interface ButtonControl extends BaseNamedControl {
|
||||||
kind: "button";
|
kind: 'button'
|
||||||
/** Unlike control's name label, this property is text on the button itself. */
|
/** Unlike control's name label, this property is text on the button itself. */
|
||||||
text: string;
|
text: string
|
||||||
/** Called when the button is pressed. */
|
/** Called when the button is pressed. */
|
||||||
action: () => void;
|
action: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Control =
|
export type Control
|
||||||
| HrControl
|
= | HrControl
|
||||||
| TextControl
|
| TextControl
|
||||||
| TextAreaControl
|
| TextAreaControl
|
||||||
| RangeControl
|
| RangeControl
|
||||||
| NumberControl
|
| NumberControl
|
||||||
| CheckboxControl
|
| CheckboxControl
|
||||||
| DropDownControl
|
| DropDownControl
|
||||||
| ButtonControl;
|
| ButtonControl
|
||||||
|
|
||||||
export type Controls = Control[];
|
export type Controls = Control[]
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,90 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AudioTrack } from "@/lib/AudioTrack";
|
import type { Controls } from '../controls'
|
||||||
import * as Easing from "@/lib/easing";
|
import type { AudioTrack } from '@/lib/AudioTrack'
|
||||||
import { ref } from "vue";
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg'
|
||||||
import type { Controls } from "../controls";
|
import { computed, ref } from 'vue'
|
||||||
import ControlsView from "../controls/ControlsView.vue";
|
import { LANGUAGES } from '@/lib/AudioTrack'
|
||||||
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
import * as Easing from '@/lib/easing'
|
||||||
|
import ControlsView from '../controls/ControlsView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audioTrack,
|
audioTrack,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
audioTrack: AudioTrack,
|
audioTrack: AudioTrack
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const easing = ref(audioTrack.ColorTransitionEasing);
|
const easing = ref(audioTrack.ColorTransitionEasing)
|
||||||
|
const season = computed<string>({
|
||||||
|
get() {
|
||||||
|
return audioTrack.Season ?? ''
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
audioTrack.Season = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const controls: Controls = [
|
const controls = computed<Controls>(() => [
|
||||||
{
|
{
|
||||||
kind: "text",
|
kind: 'text',
|
||||||
key: "Name",
|
key: 'Name',
|
||||||
name: "Name",
|
name: 'Name',
|
||||||
ref: ref(audioTrack.Name),
|
ref: ref(audioTrack.Name),
|
||||||
readonly: true,
|
readonly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "text",
|
kind: 'text',
|
||||||
key: "Artist",
|
key: 'Artist',
|
||||||
name: "Artist",
|
name: 'Artist',
|
||||||
ref: ref(audioTrack.Artist),
|
ref: ref(audioTrack.Artist),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "text",
|
kind: 'text',
|
||||||
key: "Song",
|
key: 'Song',
|
||||||
name: "Song",
|
name: 'Song',
|
||||||
ref: ref(audioTrack.Song),
|
ref: ref(audioTrack.Song),
|
||||||
disabled: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "checkbox",
|
kind: 'hr',
|
||||||
key: "IsExplicit",
|
key: 'audioTrack.hr.1',
|
||||||
name: "Is Explicit",
|
},
|
||||||
|
{
|
||||||
|
kind: 'checkbox',
|
||||||
|
key: 'IsExplicit',
|
||||||
|
name: 'Is Explicit',
|
||||||
icon: Explicit,
|
icon: Explicit,
|
||||||
ref: ref(audioTrack.IsExplicit),
|
ref: ref(audioTrack.IsExplicit),
|
||||||
label: "Explicit",
|
label: 'Explicit',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "hr",
|
kind: 'dropdown',
|
||||||
key: "audioTrack.hr.1",
|
key: 'Language',
|
||||||
|
name: 'Language',
|
||||||
|
ref: ref(audioTrack.Language),
|
||||||
|
options: LANGUAGES,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "range",
|
kind: 'text',
|
||||||
key: "BeatsOffset",
|
key: 'Season',
|
||||||
name: "Beats Offset",
|
name: 'Season',
|
||||||
|
ref: season,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'hr',
|
||||||
|
key: 'audioTrack.hr.2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'range',
|
||||||
|
key: 'BeatsOffset',
|
||||||
|
name: 'Beats Offset',
|
||||||
min: -0.5,
|
min: -0.5,
|
||||||
max: 0.5,
|
max: 0.5,
|
||||||
default: 0,
|
default: 0,
|
||||||
ref: ref(audioTrack.BeatsOffset),
|
ref: ref(audioTrack.BeatsOffset),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "range",
|
kind: 'range',
|
||||||
key: "LoopOffset",
|
key: 'LoopOffset',
|
||||||
name: "Loop Offset",
|
name: 'Loop Offset',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 128,
|
max: 128,
|
||||||
|
|
@ -67,115 +92,100 @@ const controls: Controls = [
|
||||||
ref: ref(audioTrack.LoopOffset),
|
ref: ref(audioTrack.LoopOffset),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "dropdown",
|
kind: 'number',
|
||||||
key: "Easing",
|
key: 'FadeOutBeat',
|
||||||
name: "Easing",
|
name: 'Fade Out Beat',
|
||||||
readonly: true,
|
|
||||||
ref: easing,
|
|
||||||
options: Easing.allNames,
|
|
||||||
},
|
|
||||||
// TODO: remove
|
|
||||||
// {
|
|
||||||
// kind: "dropdown",
|
|
||||||
// key: "Easing2",
|
|
||||||
// name: "Easing",
|
|
||||||
// readonly: true,
|
|
||||||
// ref: easing,
|
|
||||||
// options: Easing.allNames,
|
|
||||||
// disabled: true,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
kind: "number",
|
|
||||||
key: "LyricsIn",
|
|
||||||
name: "Lyrics In",
|
|
||||||
ref: ref(audioTrack.Lyrics[0]?.[0] ?? 0),
|
|
||||||
min: 0,
|
|
||||||
max: 1000,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "textarea",
|
|
||||||
key: "LyricsText",
|
|
||||||
name: "Lyrics Text",
|
|
||||||
ref: ref(audioTrack.Lyrics[0]?.[1] ?? ""),
|
|
||||||
monospace: true,
|
|
||||||
readonly: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "textarea",
|
|
||||||
key: "Lyrics2",
|
|
||||||
name: "Lyrics2",
|
|
||||||
ref: ref(audioTrack.Lyrics[1]?.[1] ?? ""),
|
|
||||||
monospace: true,
|
|
||||||
disabled: true,
|
|
||||||
readonly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "button",
|
|
||||||
key: "Clear",
|
|
||||||
name: "",
|
|
||||||
text: "Clear",
|
|
||||||
icon: Explicit,
|
|
||||||
action: () => {
|
|
||||||
console.log("Trigger death screen");
|
|
||||||
},
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "button",
|
|
||||||
key: "Clear2",
|
|
||||||
name: "",
|
|
||||||
text: "Trigger death screen",
|
|
||||||
// icon: Explicit,
|
|
||||||
action: () => {
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "number",
|
|
||||||
key: "FadeOutBeat",
|
|
||||||
name: "Fade Out Beat",
|
|
||||||
min: -1000,
|
min: -1000,
|
||||||
max: 1000,
|
max: 1000,
|
||||||
default: -2,
|
default: -2,
|
||||||
ref: ref(audioTrack.FadeOutBeat),
|
ref: ref(audioTrack.FadeOutBeat),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "number",
|
kind: 'number',
|
||||||
key: "FadeOutDuration",
|
key: 'FadeOutDuration',
|
||||||
name: "Fade Out Duration",
|
name: 'Fade Out Duration',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1000,
|
max: 1000,
|
||||||
default: 2,
|
default: 2,
|
||||||
ref: ref(audioTrack.FadeOutDuration),
|
ref: ref(audioTrack.FadeOutDuration),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// kind: "number",
|
kind: 'dropdown',
|
||||||
// key: "FadeOutDuration2",
|
key: 'Easing',
|
||||||
// name: "Fade Out Duration",
|
name: 'Easing',
|
||||||
// min: 0,
|
readonly: true,
|
||||||
// max: 1000,
|
ref: easing,
|
||||||
// default: 2,
|
options: Easing.allNames,
|
||||||
// ref: ref(audioTrack.FadeOutDuration),
|
},
|
||||||
// readonly: true,
|
{
|
||||||
// },
|
kind: 'range',
|
||||||
// {
|
key: 'TransitionIn',
|
||||||
// kind: "number",
|
name: 'Transition In',
|
||||||
// key: "FadeOutDuration3",
|
disabled: true,
|
||||||
// name: "Fade Out Duration",
|
min: 0,
|
||||||
// min: 0,
|
max: 1,
|
||||||
// max: 1000,
|
default: 0.25,
|
||||||
// default: 2,
|
ref: ref(audioTrack.ColorTransitionIn),
|
||||||
// ref: ref(audioTrack.FadeOutDuration),
|
},
|
||||||
// disabled: true,
|
{
|
||||||
// readonly: true,
|
kind: 'range',
|
||||||
// },
|
key: 'TransitionOut',
|
||||||
];
|
name: 'Transition Out',
|
||||||
|
disabled: true,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
default: 0.25,
|
||||||
|
ref: ref(audioTrack.ColorTransitionOut),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'hr',
|
||||||
|
key: 'audioTrack.hr.3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
key: 'LyricsIn',
|
||||||
|
name: 'Lyrics In',
|
||||||
|
ref: ref(audioTrack.Lyrics[0]?.[0] ?? 0),
|
||||||
|
min: 0,
|
||||||
|
max: 1000,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'textarea',
|
||||||
|
key: 'LyricsText',
|
||||||
|
name: 'Lyrics Text',
|
||||||
|
ref: ref(audioTrack.Lyrics[0]?.[1] ?? ''),
|
||||||
|
monospace: true,
|
||||||
|
readonly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'button',
|
||||||
|
key: 'Clear',
|
||||||
|
name: '',
|
||||||
|
text: 'Clear',
|
||||||
|
action: () => {
|
||||||
|
},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'hr',
|
||||||
|
key: 'audioTrack.hr.4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
key: 'GameOverText',
|
||||||
|
name: 'Game Over',
|
||||||
|
ref: ref(audioTrack.GameOverText),
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:select-none">
|
<div class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:select-none">
|
||||||
<h3 class="tw:text-sm">Audio Track</h3>
|
<h3 class="tw:text-sm">
|
||||||
|
Audio Track
|
||||||
|
</h3>
|
||||||
<ControlsView :controls />
|
<ControlsView :controls />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { shallowRef, useTemplateRef } from 'vue';
|
import { shallowRef, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
// Any card with a subtle outline and hover effect
|
// Any card with a subtle outline and hover effect
|
||||||
const {
|
const {
|
||||||
|
|
@ -7,84 +7,86 @@ const {
|
||||||
playheadEnabled = true,
|
playheadEnabled = true,
|
||||||
selected = false,
|
selected = false,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
hoverEnabled?: boolean,
|
hoverEnabled?: boolean
|
||||||
playheadEnabled?: boolean,
|
playheadEnabled?: boolean
|
||||||
selected?: boolean,
|
selected?: boolean
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'select'): void;
|
(e: 'select'): void
|
||||||
(e: 'activate'): void;
|
(e: 'activate'): void
|
||||||
(e: 'playhead', pos: number): void;
|
(e: 'playhead', pos: number): void
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
// Timeline / playhead position on hover in range 0..1,
|
// Timeline / playhead position on hover in range 0..1,
|
||||||
// or NaN when not hovered or hover is not enabled.
|
// or NaN when not hovered or hover is not enabled.
|
||||||
const playheadPosition01 = shallowRef<number>(NaN);
|
const playheadPosition01 = shallowRef<number>(Number.NaN)
|
||||||
const playheadEl = useTemplateRef('playheadEl');
|
const playheadEl = useTemplateRef('playheadEl')
|
||||||
const card = useTemplateRef('card');
|
const card = useTemplateRef('card')
|
||||||
|
|
||||||
// Simply tracks pointer enter/leave
|
// Simply tracks pointer enter/leave
|
||||||
const isHovered = shallowRef(false);
|
const isHovered = shallowRef(false)
|
||||||
// flag to detect a recent touch tap so the following click doesn't also emit 'select'
|
// flag to detect a recent touch tap so the following click doesn't also emit 'select'
|
||||||
const lastTapWasTouch = shallowRef(false);
|
const lastTapWasTouch = shallowRef(false)
|
||||||
// show playhead on touch while pressing
|
// show playhead on touch while pressing
|
||||||
const isTouchActive = shallowRef(false);
|
const isTouchActive = shallowRef(false)
|
||||||
// Once dragging starts, pointer should no longer cause click on release/up
|
// Once dragging starts, pointer should no longer cause click on release/up
|
||||||
const isTouchDragging = shallowRef(false);
|
const isTouchDragging = shallowRef(false)
|
||||||
// Playhead is active on mouse hover or touch down, but not after touch up which leaves dangling :hover on mobile.
|
// Playhead is active on mouse hover or touch down, but not after touch up which leaves dangling :hover on mobile.
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
playheadPosition01,
|
playheadPosition01,
|
||||||
});
|
})
|
||||||
|
|
||||||
// Returns false if playhead wasn't updated
|
// Returns false if playhead wasn't updated
|
||||||
function updatePlayhead(event: PointerEvent): boolean {
|
function updatePlayhead(event: PointerEvent): boolean {
|
||||||
const target = event.currentTarget as HTMLElement | null;
|
const target = event.currentTarget as HTMLElement | null
|
||||||
if (!hoverEnabled || !playheadEnabled || !target) {
|
if (!hoverEnabled || !playheadEnabled || !target) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect()
|
||||||
const x = event.clientX - rect.left;
|
const x = event.clientX - rect.left
|
||||||
const pos = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0;
|
const pos = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0
|
||||||
playheadPosition01.value = pos;
|
playheadPosition01.value = pos
|
||||||
|
|
||||||
if (playheadEl.value) {
|
if (playheadEl.value) {
|
||||||
// position the 1px playhead using percentage so it adapts to responsive widths
|
// position the 1px playhead using percentage so it adapts to responsive widths
|
||||||
playheadEl.value.style.left = `${pos * 100}%`;
|
playheadEl.value.style.left = `${pos * 100}%`
|
||||||
}
|
}
|
||||||
// emit normalized position for parent components to react (e.g. preview color)
|
// emit normalized position for parent components to react (e.g. preview color)
|
||||||
emit('playhead', pos);
|
emit('playhead', pos)
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerEnter(_event: PointerEvent) {
|
function onPointerEnter(_event: PointerEvent) {
|
||||||
if (hoverEnabled) {
|
if (hoverEnabled) {
|
||||||
isHovered.value = true;
|
isHovered.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerLeave(_event: PointerEvent) {
|
function onPointerLeave(_event: PointerEvent) {
|
||||||
if (hoverEnabled) {
|
if (hoverEnabled) {
|
||||||
isHovered.value = false;
|
isHovered.value = false
|
||||||
}
|
}
|
||||||
isTouchActive.value = false;
|
isTouchActive.value = false
|
||||||
isTouchDragging.value = false;
|
isTouchDragging.value = false
|
||||||
playheadPosition01.value = NaN;
|
playheadPosition01.value = Number.NaN
|
||||||
if (playheadEl.value) {
|
if (playheadEl.value) {
|
||||||
playheadEl.value.style.left = "";
|
playheadEl.value.style.left = ''
|
||||||
}
|
}
|
||||||
emit('playhead', NaN);
|
emit('playhead', Number.NaN)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(event: PointerEvent) {
|
function onPointerDown(event: PointerEvent) {
|
||||||
if (event.pointerType !== 'touch') return;
|
if (event.pointerType !== 'touch') {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (updatePlayhead(event)) {
|
if (updatePlayhead(event)) {
|
||||||
isTouchActive.value = true;
|
isTouchActive.value = true
|
||||||
}
|
}
|
||||||
if (card.value) {
|
if (card.value) {
|
||||||
card.value.setPointerCapture(event.pointerId);
|
card.value.setPointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,55 +94,60 @@ function onPointerUp(event: PointerEvent) {
|
||||||
// Treat a single touch tap as activation
|
// Treat a single touch tap as activation
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
if (!isTouchDragging.value) {
|
if (!isTouchDragging.value) {
|
||||||
emit('activate');
|
emit('activate')
|
||||||
}
|
}
|
||||||
lastTapWasTouch.value = true;
|
lastTapWasTouch.value = true
|
||||||
// keep the flag true briefly so the subsequent click handler can suppress 'select'
|
// keep the flag true briefly so the subsequent click handler can suppress 'select'
|
||||||
setTimeout(() => { lastTapWasTouch.value = false; }, 50);
|
setTimeout(() => {
|
||||||
|
lastTapWasTouch.value = false
|
||||||
|
}, 50)
|
||||||
// clear touch-active playhead state
|
// clear touch-active playhead state
|
||||||
isTouchActive.value = false;
|
isTouchActive.value = false
|
||||||
isTouchDragging.value = false;
|
isTouchDragging.value = false
|
||||||
onPointerLeave(event);
|
onPointerLeave(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(event: PointerEvent) {
|
function onPointerMove(event: PointerEvent) {
|
||||||
updatePlayhead(event);
|
updatePlayhead(event)
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
isTouchDragging.value = true;
|
isTouchDragging.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(event: MouseEvent) {
|
function onClick(event: MouseEvent) {
|
||||||
// If this click follows a touch tap, suppress the 'select' event
|
// If this click follows a touch tap, suppress the 'select' event
|
||||||
if (lastTapWasTouch.value) {
|
if (lastTapWasTouch.value) {
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
lastTapWasTouch.value = false;
|
lastTapWasTouch.value = false
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
emit('select');
|
emit('select')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDblClick(_event: MouseEvent) {
|
function onDblClick(_event: MouseEvent) {
|
||||||
emit('activate')
|
emit('activate')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="card" class="card card-border tw:min-w-10 tw:min-h-10 tw:grid" :class="{
|
<div
|
||||||
|
ref="card" class="card card-border tw:min-w-10 tw:min-h-10 tw:grid" :class="{
|
||||||
'hover-enabled': hoverEnabled,
|
'hover-enabled': hoverEnabled,
|
||||||
'playhead-enabled': hoverEnabled && playheadEnabled,
|
'playhead-enabled': hoverEnabled && playheadEnabled,
|
||||||
'playhead-active': isHovered || isTouchActive,
|
'playhead-active': isHovered || isTouchActive,
|
||||||
selected,
|
selected,
|
||||||
}" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @pointerdown="onPointerDown"
|
}" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @pointerdown="onPointerDown"
|
||||||
@pointerup="onPointerUp" @pointermove="onPointerMove" @click="onClick" @dblclick="onDblClick"
|
@pointerup="onPointerUp" @pointermove="onPointerMove" @click="onClick" @dblclick="onDblClick"
|
||||||
@focusin="emit('select')">
|
@focusin="emit('select')"
|
||||||
|
>
|
||||||
<!-- content container -->
|
<!-- content container -->
|
||||||
<div class="tw:row-span-full tw:col-span-full tw:w-full tw:h-full">
|
<div class="tw:row-span-full tw:col-span-full tw:w-full tw:h-full">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<!-- playhead container -->
|
<!-- playhead container -->
|
||||||
<div class="playhead-container tw:pointer-events-none tw:row-span-full tw:col-span-full">
|
<div class="playhead-container tw:pointer-events-none tw:row-span-full tw:col-span-full">
|
||||||
<div ref="playheadEl" class="playhead"></div>
|
<div ref="playheadEl" class="playhead" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<hr class="card-separator" />
|
<hr class="card-separator">
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference "tailwindcss";
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
color: string,
|
color: string
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:rounded-full tw:h-6 tw:w-6 tw:border-2 tw:border-neutral-50"
|
<div
|
||||||
|
class="tw:rounded-full tw:h-6 tw:w-6 tw:border-2 tw:border-neutral-50"
|
||||||
style="outline: 0.5px solid rgba(0, 0, 0, 0.6); outline-offset: -2px;" :style="{
|
style="outline: 0.5px solid rgba(0, 0, 0, 0.6); outline-offset: -2px;" :style="{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
}" />
|
}"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,108 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionHeader from '@/components/library/SectionHeader.vue';
|
import type { AudioTrack } from '@/lib/AudioTrack'
|
||||||
import SearchField from '@/components/SearchField.vue';
|
import FilterNone from '@material-design-icons/svg/outlined/filter_none.svg'
|
||||||
import { type AudioTrack } from '@/lib/AudioTrack';
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
import { useTrackStore } from '@/store/TrackStore';
|
import SectionHeader from '@/components/library/SectionHeader.vue'
|
||||||
import FilterNone from '@material-design-icons/svg/outlined/filter_none.svg';
|
import SearchField from '@/components/SearchField.vue'
|
||||||
import { computed, ref, shallowRef } from 'vue';
|
import { useTrackStore } from '@/store/TrackStore'
|
||||||
import TrackCard from './TrackCard.vue';
|
import Footer from '../Footer.vue'
|
||||||
import Footer from '../Footer.vue';
|
import TrackCard from './TrackCard.vue'
|
||||||
|
|
||||||
const trackStore = useTrackStore();
|
const trackStore = useTrackStore()
|
||||||
|
|
||||||
const selectedTrackName = ref<string | null>(null);
|
const selectedTrackName = ref<string | null>(null)
|
||||||
// selectedTrackName.value = "HowLow";
|
|
||||||
|
|
||||||
const filterText = shallowRef("");
|
const filterText = shallowRef('')
|
||||||
|
|
||||||
const fuzzySubsequence = (needle: string, haystack: string): boolean => {
|
const fuzzySubsequence = (needle: string, haystack: string): boolean => {
|
||||||
// returns true if all chars of needle appear in haystack in order
|
// returns true if all chars of needle appear in haystack in order
|
||||||
let i = 0;
|
let i = 0
|
||||||
for (let j = 0; j < haystack.length && i < needle.length; j++) {
|
for (let j = 0; j < haystack.length && i < needle.length; j++) {
|
||||||
if (haystack[j] === needle[i]) i++;
|
if (haystack[j] === needle[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i === needle.length
|
||||||
}
|
}
|
||||||
return i === needle.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const trackMatches = (track: AudioTrack): boolean => {
|
const trackMatches = (track: AudioTrack): boolean => {
|
||||||
const q = filterText.value.trim().toLowerCase();
|
const q = filterText.value.trim().toLowerCase()
|
||||||
if (q === "") return true;
|
if (q === '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// split into tokens so e.g. "imagine drag" matches "Imagine Dragons"
|
// split into tokens so e.g. "imagine drag" matches "Imagine Dragons"
|
||||||
const tokens = q.split(/\s+/).filter(Boolean);
|
const tokens = q.split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
// gather candidate fields to search (lowercased)
|
// gather candidate fields to search (lowercased)
|
||||||
const fields: string[] = [];
|
const fields: string[] = []
|
||||||
const pushField = (v?: string) => {
|
const pushField = (v?: string) => {
|
||||||
if (typeof v === "string" && v.length > 0) {
|
if (typeof v === 'string' && v.length > 0) {
|
||||||
fields.push(v.toLowerCase());
|
fields.push(v.toLowerCase())
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
pushField(track.Name);
|
pushField(track.Name)
|
||||||
pushField(track.Artist);
|
pushField(track.Artist)
|
||||||
pushField(track.Song);
|
pushField(track.Song)
|
||||||
|
|
||||||
// for each token, require it to match at least one field (via includes or fuzzy match)
|
// for each token, require it to match at least one field (via includes or fuzzy match)
|
||||||
return tokens.every((token) => {
|
return tokens.every((token) => {
|
||||||
return fields.some((field) => field.includes(token) || fuzzySubsequence(token, field));
|
return fields.some(field => field.includes(token) || fuzzySubsequence(token, field))
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const filteredGroupedSortedTracks = computed(() => {
|
const filteredGroupedSortedTracks = computed(() => {
|
||||||
if (filterText.value === "") {
|
if (filterText.value === '') {
|
||||||
return trackStore.groupedSortedTracks;
|
return trackStore.groupedSortedTracks
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
return trackStore.groupedSortedTracks
|
return trackStore.groupedSortedTracks
|
||||||
.map(([language, tracks]) => {
|
.map(([language, tracks]) => {
|
||||||
tracks = tracks.filter(trackMatches);
|
tracks = tracks.filter(trackMatches)
|
||||||
return [language, tracks] as const;
|
return [language, tracks] as const
|
||||||
})
|
})
|
||||||
// remove empty languages
|
// remove empty languages
|
||||||
.filter(([_language, tracks]) => tracks.length > 0);
|
.filter(([_language, tracks]) => tracks.length > 0)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
const filteredIsEmpty = computed(() => filteredGroupedSortedTracks.value.length === 0);
|
const filteredIsEmpty = computed(() => filteredGroupedSortedTracks.value.length === 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:flex tw:flex-col tw:h-full">
|
<div class="tw:flex tw:flex-col tw:h-full">
|
||||||
<!-- TODO: static positioning does not work in flex? -->
|
<!-- TODO: static positioning does not work in flex? -->
|
||||||
<div class="tw:flex-none tw:top-0 tw:pt-4 tw:px-8 tw:max-sm:px-4 tw:flex tw:justify-center"
|
<div
|
||||||
style="position: static;">
|
class="tw:flex-none tw:top-0 tw:pt-4 tw:px-8 tw:max-sm:px-4 tw:flex tw:justify-center"
|
||||||
|
style="position: static;"
|
||||||
|
>
|
||||||
<SearchField v-model="filterText" class="tw:flex-1 tw:max-w-72 tw:max-sm:max-w-full" />
|
<SearchField v-model="filterText" class="tw:flex-1 tw:max-w-72 tw:max-sm:max-w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredIsEmpty" class="tw:flex-1 tw:flex tw:flex-col tw:items-center tw:justify-center tw:gap-2"
|
<div
|
||||||
style="color: #929292;">
|
v-if="filteredIsEmpty" class="tw:flex-1 tw:flex tw:flex-col tw:items-center tw:justify-center tw:gap-2"
|
||||||
|
style="color: #929292;"
|
||||||
|
>
|
||||||
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
|
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
|
||||||
<p class="tw:text-2xl tw:font-bold">No tracks found</p>
|
<p class="tw:text-2xl tw:font-bold">
|
||||||
|
No tracks found
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else
|
<div
|
||||||
|
v-else
|
||||||
class="tw:flex-none tw:grid tw:px-8 tw:pb-8 tw:max-sm:px-4 tw:max-sm:pb-4 tw:gap-4 tw:max-sm:columns-1" style="
|
class="tw:flex-none tw:grid tw:px-8 tw:pb-8 tw:max-sm:px-4 tw:max-sm:pb-4 tw:gap-4 tw:max-sm:columns-1" style="
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr));
|
||||||
">
|
"
|
||||||
<template v-for="[language, tracks] in filteredGroupedSortedTracks">
|
>
|
||||||
<SectionHeader class="tw:col-span-full">{{ language }}</SectionHeader>
|
<template v-for="[language, tracks] in filteredGroupedSortedTracks" :key="language">
|
||||||
<TrackCard v-for="track in tracks" :track :selected="track.Name === selectedTrackName"
|
<SectionHeader class="tw:col-span-full">
|
||||||
@select="selectedTrackName = track.Name" />
|
{{ language }}
|
||||||
|
</SectionHeader>
|
||||||
|
<TrackCard
|
||||||
|
v-for="track in tracks"
|
||||||
|
:key="track.Name"
|
||||||
|
:track
|
||||||
|
:selected="track.Name === selectedTrackName"
|
||||||
|
@select="selectedTrackName = track.Name"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="tw:text-3xl tw:p-4 tw:pb-2 tw:text-center">
|
<h2 class="tw:text-3xl tw:p-4 tw:pb-2 tw:text-center">
|
||||||
<slot />
|
<slot />
|
||||||
</h2>
|
</h2>
|
||||||
<hr class="section-header__separator" />
|
<hr class="section-header__separator">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
/** Slider's orientation */
|
/** Slider's orientation */
|
||||||
export type Orientation = "horizontal" | "vertical";
|
export type Orientation = 'horizontal' | 'vertical'
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useAttrs, useId } from 'vue';
|
import type { Orientation } from './Slider'
|
||||||
import classes from './ToolBar.module.css';
|
import { computed, useAttrs, useId } from 'vue'
|
||||||
import type { Orientation } from "./Slider";
|
import classes from './ToolBar.module.css'
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
min,
|
min,
|
||||||
|
|
@ -9,45 +11,48 @@ const {
|
||||||
step,
|
step,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
reset,
|
reset,
|
||||||
orientation = "horizontal",
|
orientation = 'horizontal',
|
||||||
title,
|
title,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
min?: number,
|
min?: number
|
||||||
max?: number,
|
max?: number
|
||||||
step?: number,
|
step?: number
|
||||||
defaultValue?: number,
|
defaultValue?: number
|
||||||
reset?: () => void,
|
reset?: () => void
|
||||||
orientation?: Orientation,
|
orientation?: Orientation
|
||||||
title?: string,
|
title?: string
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
const attrs = useAttrs()
|
||||||
const attrs = useAttrs();
|
|
||||||
|
|
||||||
const isVertical = computed(() => orientation === "vertical");
|
const isVertical = computed(() => orientation === 'vertical')
|
||||||
const orient = computed(() => orientation === "vertical" ? "vertical" : null);
|
const orient = computed(() => orientation === 'vertical' ? 'vertical' : null)
|
||||||
|
|
||||||
const model = defineModel<number>();
|
const model = defineModel<number>()
|
||||||
|
|
||||||
function dblclickHandler(event: MouseEvent) {
|
function dblclickHandler(event: MouseEvent) {
|
||||||
if (reset !== undefined) {
|
if (reset !== undefined) {
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
reset();
|
reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markersListId = useId();
|
const markersListId = useId()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input type="range" :min :max :step v-model.number="model" :orient :title @dblclick="dblclickHandler"
|
<input
|
||||||
class="slider tw:flex-1 tw:basis-20"
|
v-model.number="model" type="range" :min :max :step :orient :title class="slider tw:flex-1 tw:basis-20"
|
||||||
:class="[classes.toolbarControl, isVertical ? 'tw:min-h-10 tw:max-h-40' : 'tw:min-w-10 tw:max-w-40']"
|
:class="[classes.toolbarControl, isVertical ? 'tw:min-h-10 tw:max-h-40' : 'tw:min-w-10 tw:max-w-40']"
|
||||||
:list="markersListId" v-bind="attrs" />
|
:list="markersListId"
|
||||||
|
v-bind="attrs" @dblclick="dblclickHandler"
|
||||||
|
>
|
||||||
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
|
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
|
||||||
<datalist :id="markersListId">
|
<datalist :id="markersListId">
|
||||||
<option v-if="defaultValue !== undefined" :value="defaultValue"></option>
|
<option v-if="defaultValue !== undefined" :value="defaultValue" />
|
||||||
</datalist>
|
</datalist>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.slider {
|
.slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|
@ -126,7 +131,6 @@ const markersListId = useId();
|
||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
.slider::-moz-range-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-toggle {
|
.toolbar-toggle {
|
||||||
|
/* prevent CSS minifier/Tailwind purge from removing this rule */
|
||||||
|
--toolbar-toggle-keep: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-toggle-checked {
|
.toolbar-toggle-checked {
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue'
|
||||||
import classes from './ToolBar.module.css';
|
import classes from './ToolBar.module.css'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
icon: string | Component,
|
icon: string | Component
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button type="button" :class="[classes.toolButton, classes.toolbarControl]">
|
<button type="button" :class="[classes.toolButton, classes.toolbarControl]">
|
||||||
<component :is="icon" />
|
<component :is="icon" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue'
|
||||||
import classes from './ToolBar.module.css';
|
import classes from './ToolBar.module.css'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
icon: string | Component,
|
icon: string | Component
|
||||||
disabled?: boolean,
|
disabled?: boolean
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button type="button" :class="[classes.toolButton, classes.toolButtonSmall]" :disabled>
|
<button type="button" :class="[classes.toolButton, classes.toolButtonSmall]" :disabled>
|
||||||
<component :is="icon" />
|
<component :is="icon" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue'
|
||||||
import classes from './ToolBar.module.css';
|
import classes from './ToolBar.module.css'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
checked: boolean,
|
checked: boolean
|
||||||
icon: string | Component,
|
icon: string | Component
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button type="button"
|
<button
|
||||||
:class="[classes.toolButton, classes.toolbarControl, classes.toolbarToggle, checked ? classes.toolbarToggleChecked : undefined]">
|
type="button"
|
||||||
|
:class="[
|
||||||
|
classes.toolButton,
|
||||||
|
classes.toolbarControl,
|
||||||
|
classes.toolbarToggle,
|
||||||
|
checked ? classes.toolbarToggleChecked : undefined,
|
||||||
|
]"
|
||||||
|
>
|
||||||
<component :is="icon" />
|
<component :is="icon" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,61 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Card from '@/components/library/Card.vue';
|
import type { AudioTrack } from '@/lib/AudioTrack'
|
||||||
import ColorSwatch from '@/components/library/ColorSwatch.vue';
|
import AutoAwesome from '@material-design-icons/svg/filled/auto_awesome.svg'
|
||||||
import { openTrack } from '@/router';
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg'
|
||||||
import { formatTime, timeSeriesIsEmpty, type AudioTrack } from '@/lib/AudioTrack';
|
import Lyrics from '@material-design-icons/svg/filled/lyrics.svg'
|
||||||
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
import Event from '@material-design-icons/svg/two-tone/event.svg'
|
||||||
import Lyrics from '@material-design-icons/svg/filled/lyrics.svg';
|
import LibraryMusic from '@material-design-icons/svg/two-tone/library_music.svg'
|
||||||
import AutoAwesome from '@material-design-icons/svg/filled/auto_awesome.svg';
|
import { computed, shallowRef } from 'vue'
|
||||||
import LibraryMusic from '@material-design-icons/svg/two-tone/library_music.svg';
|
import Card from '@/components/library/Card.vue'
|
||||||
import { computed, shallowRef } from 'vue';
|
import ColorSwatch from '@/components/library/ColorSwatch.vue'
|
||||||
import TrackCardBadge from './TrackCardBadge.vue';
|
import { formatTime, timeSeriesIsEmpty } from '@/lib/AudioTrack'
|
||||||
import CardSeparator from './CardSeparator.vue';
|
import { openTrack } from '@/router'
|
||||||
|
import CardSeparator from './CardSeparator.vue'
|
||||||
|
import TrackCardBadge from './TrackCardBadge.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
track,
|
track,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: AudioTrack,
|
track: AudioTrack
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const hasLyrics = computed<boolean>(() => track.Lyrics.length !== 0);
|
const hasSeason = computed<boolean>(() => track.Season !== null)
|
||||||
|
const hasLyrics = computed<boolean>(() => track.Lyrics.length !== 0)
|
||||||
const hasEffects = computed<boolean>(() =>
|
const hasEffects = computed<boolean>(() =>
|
||||||
timeSeriesIsEmpty(track.DrunknessLoopOffsetTimeSeries) ||
|
timeSeriesIsEmpty(track.DrunknessLoopOffsetTimeSeries)
|
||||||
timeSeriesIsEmpty(track.CondensationLoopOffsetTimeSeries)
|
|| timeSeriesIsEmpty(track.CondensationLoopOffsetTimeSeries),
|
||||||
);
|
)
|
||||||
|
|
||||||
const trackPalettePreview = computed<string[]>(() => track.Palette.slice(0, 6));
|
const trackPalettePreview = computed<string[]>(() => track.Palette.slice(0, 6))
|
||||||
|
|
||||||
// preview color per track name (reactive map)
|
// preview color per track name (reactive map)
|
||||||
const previewColor = shallowRef<string | undefined>(undefined);
|
const previewColor = shallowRef<string | undefined>(undefined)
|
||||||
|
|
||||||
function updatePreview(pos: number) {
|
function updatePreview(pos: number) {
|
||||||
if (!track || !track.Palette || track.Palette.length === 0) return;
|
if (!track || !track.Palette || track.Palette.length === 0) {
|
||||||
if (!isFinite(pos)) {
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(pos)) {
|
||||||
// reset to default
|
// reset to default
|
||||||
previewColor.value = "";
|
previewColor.value = ''
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
// pick an index in the palette (wrap)
|
// pick an index in the palette (wrap)
|
||||||
const n = track.Palette.length;
|
const n = track.Palette.length
|
||||||
const idx = Math.floor(pos * n) % n;
|
const idx = Math.floor(pos * n) % n
|
||||||
previewColor.value = track.Palette[idx] || undefined;
|
previewColor.value = track.Palette[idx] || undefined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="card" tabindex="0" @activate="openTrack(track)" @playhead="(pos) => updatePreview(pos)">
|
<Card class="card" tabindex="0" @activate="openTrack(track)" @playhead="(pos) => updatePreview(pos)">
|
||||||
<div class="card-grid tw:grid tw:p-2 tw:gap-2">
|
<div class="card-grid tw:grid tw:p-2 tw:gap-2">
|
||||||
|
|
||||||
<!-- preview -->
|
<!-- preview -->
|
||||||
<!-- square aspect trick didn't work in grid, reverted to fixed size -->
|
<!-- square aspect trick didn't work in grid, reverted to fixed size -->
|
||||||
<div style="grid-area: preview;"
|
<div
|
||||||
class="tw:w-32 tw:h-32 tw:max-sm:max-w-24 tw:max-sm:max-h-24 tw:self-center card-border">
|
style="grid-area: preview;"
|
||||||
|
class="tw:w-32 tw:h-32 tw:max-sm:max-w-24 tw:max-sm:max-h-24 tw:self-center card-border"
|
||||||
|
>
|
||||||
<LibraryMusic class="tw:h-full tw:w-full card-preview" :style="{ color: previewColor }" />
|
<LibraryMusic class="tw:h-full tw:w-full card-preview" :style="{ color: previewColor }" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -60,6 +67,7 @@ function updatePreview(pos: number) {
|
||||||
</h3>
|
</h3>
|
||||||
<TrackCardBadge v-if="track.IsExplicit" :icon="Explicit" title="Warning: Explicit Content" />
|
<TrackCardBadge v-if="track.IsExplicit" :icon="Explicit" title="Warning: Explicit Content" />
|
||||||
<div class="tw:flex-1" /> <!-- separator -->
|
<div class="tw:flex-1" /> <!-- separator -->
|
||||||
|
<TrackCardBadge v-if="hasSeason" :icon="Event" :title="`Seasonal Content (${track.Season})`" />
|
||||||
<TrackCardBadge v-if="hasLyrics" :icon="Lyrics" title="Contains Lyrics" />
|
<TrackCardBadge v-if="hasLyrics" :icon="Lyrics" title="Contains Lyrics" />
|
||||||
<TrackCardBadge v-if="hasEffects" :icon="AutoAwesome" title="Contains Visual Effects" />
|
<TrackCardBadge v-if="hasEffects" :icon="AutoAwesome" title="Contains Visual Effects" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,19 +83,25 @@ function updatePreview(pos: number) {
|
||||||
|
|
||||||
<!-- palette -->
|
<!-- palette -->
|
||||||
<div style="grid-area: palette;" class="tw:self-center tw:py-1 tw:max-sm:ps-2 tw:flex tw:gap-1">
|
<div style="grid-area: palette;" class="tw:self-center tw:py-1 tw:max-sm:ps-2 tw:flex tw:gap-1">
|
||||||
<ColorSwatch v-for="color in trackPalettePreview" :color />
|
<ColorSwatch v-for="color, i in trackPalettePreview" :key="i" :color />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- timing -->
|
<!-- timing -->
|
||||||
<div style="grid-area: timing;" class="tw:flex tw:flex-col tw:text-xs">
|
<div style="grid-area: timing;" class="tw:flex tw:flex-col tw:text-xs">
|
||||||
<div title="Intro duration" class="tw:font-mono">{{ formatTime(track.WindUpTimer) }}</div>
|
<div title="Intro duration" class="tw:font-mono">
|
||||||
<div title="Loop offset" class="tw:font-mono" v-if="track.LoopOffset > 0">{{ track.LoopOffset }} beats</div>
|
{{ formatTime(track.WindUpTimer) }}
|
||||||
<div title="Loop duration" class="tw:font-mono">{{ formatTime(track.FileDurationLoop) }}</div>
|
</div>
|
||||||
|
<div v-if="track.LoopOffset > 0" title="Loop offset" class="tw:font-mono">
|
||||||
|
{{ track.LoopOffset }} beats
|
||||||
|
</div>
|
||||||
|
<div title="Loop duration" class="tw:font-mono">
|
||||||
|
{{ formatTime(track.FileDurationLoop) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference "tailwindcss";
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
icon: string | Component,
|
icon: string | Component
|
||||||
title: string,
|
title: string
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :title class="tw:flex-none tw:z-10">
|
<div :title class="tw:flex-none tw:z-10">
|
||||||
<component :is="icon" class="tw:w-6 tw:h-6 tw:fill-current" />
|
<component :is="icon" class="tw:w-6 tw:h-6 tw:fill-current" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,93 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Slider from '@/components/library/Slider.vue';
|
import VolumeDown from '@material-design-icons/svg/outlined/volume_down.svg'
|
||||||
import VolumeDown from '@material-design-icons/svg/outlined/volume_down.svg';
|
import VolumeMute from '@material-design-icons/svg/outlined/volume_mute.svg'
|
||||||
import VolumeMute from '@material-design-icons/svg/outlined/volume_mute.svg';
|
import VolumeOff from '@material-design-icons/svg/outlined/volume_off.svg'
|
||||||
import VolumeOff from '@material-design-icons/svg/outlined/volume_off.svg';
|
import VolumeUp from '@material-design-icons/svg/outlined/volume_up.svg'
|
||||||
import VolumeUp from '@material-design-icons/svg/outlined/volume_up.svg';
|
import { computed, useId } from 'vue'
|
||||||
import { computed, useId } from 'vue';
|
import Slider from '@/components/library/Slider.vue'
|
||||||
import classes from './ToolBar.module.css';
|
import classes from './ToolBar.module.css'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
defaultVolume = 1,
|
defaultVolume = 1,
|
||||||
enableBoost = true,
|
enableBoost = true,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
defaultVolume?: number,
|
defaultVolume?: number
|
||||||
// Boost increases volume range from 0..1 up to 0..1.5
|
// Boost increases volume range from 0..1 up to 0..1.5
|
||||||
enableBoost?: boolean,
|
enableBoost?: boolean
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
/** Muted flag. When muted, volume slider shows zero value, but remains interactive. */
|
/** Muted flag. When muted, volume slider shows zero value, but remains interactive. */
|
||||||
const muted = defineModel<boolean>('muted', { required: true });
|
const muted = defineModel<boolean>('muted', { required: true })
|
||||||
|
|
||||||
/** Volume in range 0..1 or 0..1.5 if boost is enabled. */
|
/** Volume in range 0..1 or 0..1.5 if boost is enabled. */
|
||||||
const volume = defineModel<number>('volume', { required: true });
|
const volume = defineModel<number>('volume', { required: true })
|
||||||
|
|
||||||
const mutedId = useId();
|
const mutedId = useId()
|
||||||
const mutedTitle = computed(() => muted.value ? 'Unmute' : 'Mute');
|
const mutedTitle = computed(() => muted.value ? 'Unmute' : 'Mute')
|
||||||
|
|
||||||
function toggleMuted() {
|
function toggleMuted() {
|
||||||
muted.value = !muted.value;
|
muted.value = !muted.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeMax = computed(() => enableBoost ? 1.5 : 1.0);
|
const volumeMax = computed(() => enableBoost ? 1.5 : 1.0)
|
||||||
const sliderSteps = computed(() => enableBoost ? 24 : 16);
|
const sliderSteps = computed(() => enableBoost ? 24 : 16)
|
||||||
|
|
||||||
function toSteps(volume: number): number {
|
function toSteps(volume: number): number {
|
||||||
return volume / volumeMax.value * sliderSteps.value;
|
return volume / volumeMax.value * sliderSteps.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromSteps(steps: number): number {
|
function fromSteps(steps: number): number {
|
||||||
return steps / sliderSteps.value * volumeMax.value;
|
return steps / sliderSteps.value * volumeMax.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeDisplay = computed<number>({
|
const volumeDisplay = computed<number>({
|
||||||
get() {
|
get() {
|
||||||
// displays zero volume when muted, despite actual unmuted volume is remembered
|
// displays zero volume when muted, despite actual unmuted volume is remembered
|
||||||
return muted.value ? 0 : toSteps(volume.value);
|
return muted.value ? 0 : toSteps(volume.value)
|
||||||
},
|
},
|
||||||
set(value: number) {
|
set(value: number) {
|
||||||
volume.value = fromSteps(value);
|
volume.value = fromSteps(value)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
volume.value = defaultVolume;
|
volume.value = defaultVolume
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValue = computed(() => toSteps(defaultVolume));
|
const defaultValue = computed(() => toSteps(defaultVolume))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center">
|
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center">
|
||||||
<input :id="mutedId" type="checkbox" class="tw:hidden" :title="mutedTitle" v-on:click="toggleMuted" />
|
<input
|
||||||
<label :for="mutedId" :class="[classes.toolbarControl, classes.toolButton]" :title="mutedTitle" tabindex="0">
|
:id="mutedId"
|
||||||
|
type="checkbox"
|
||||||
|
class="tw:hidden"
|
||||||
|
:title="mutedTitle"
|
||||||
|
@click="toggleMuted"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
:for="mutedId"
|
||||||
|
:class="[classes.toolbarControl, classes.toolButton]"
|
||||||
|
:title="mutedTitle"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
<VolumeOff v-if="muted" class="tw:text-[#e64b3d]" />
|
<VolumeOff v-if="muted" class="tw:text-[#e64b3d]" />
|
||||||
<!-- transforms are needed because icons are centered rather than aligned with each other -->
|
<!-- transforms are needed because icons are centered rather than aligned with each other -->
|
||||||
<VolumeMute v-else-if="volume < 0.33" style="transform: translateX(-8px);" />
|
<VolumeMute v-else-if="volume < 0.33" style="transform: translateX(-8px);" />
|
||||||
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
|
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
|
||||||
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
|
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
|
||||||
</label>
|
</label>
|
||||||
<Slider :min="0" :max="sliderSteps" :step="1" v-model.number="volumeDisplay" :reset="reset" :defaultValue
|
<Slider
|
||||||
title="Volume" />
|
v-model.number="volumeDisplay"
|
||||||
|
:min="0"
|
||||||
|
:max="sliderSteps"
|
||||||
|
:step="1"
|
||||||
|
:reset="reset"
|
||||||
|
:default-value
|
||||||
|
title="Volume"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,40 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Slider from '@/components/library/Slider.vue';
|
import type { Orientation } from './Slider'
|
||||||
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
import type { UseZoomAxis } from '@/lib/useZoomAxis'
|
||||||
import Add from "@material-design-icons/svg/filled/add.svg";
|
import Add from '@material-design-icons/svg/filled/add.svg'
|
||||||
import Remove from "@material-design-icons/svg/filled/remove.svg";
|
import Remove from '@material-design-icons/svg/filled/remove.svg'
|
||||||
import type { Orientation } from "./Slider";
|
import Slider from '@/components/library/Slider.vue'
|
||||||
import ToolButtonSmall from './ToolButtonSmall.vue';
|
import ToolButtonSmall from './ToolButtonSmall.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
axis,
|
axis,
|
||||||
orientation = "horizontal",
|
orientation = 'horizontal',
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
axis: UseZoomAxis,
|
axis: UseZoomAxis
|
||||||
orientation?: Orientation,
|
orientation?: Orientation
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- for some reason min-width does not propagate up from Slider -->
|
<!-- for some reason min-width does not propagate up from Slider -->
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:px-2 tw:flex tw:items-center tw:gap-2"
|
<div
|
||||||
:class="orientation == 'vertical' ? 'tw:flex-col' : 'tw:flex-row'">
|
class="tw:px-2 tw:flex tw:items-center tw:gap-2"
|
||||||
|
:class="orientation === 'vertical' ? 'tw:flex-col' : 'tw:flex-row'"
|
||||||
<ToolButtonSmall :icon="Remove" title="Zoom Out" @click="axis.zoomOut" :disabled="axis.isAtMin.value" />
|
>
|
||||||
|
<ToolButtonSmall :icon="Remove" title="Zoom Out" :disabled="axis.isAtMin.value" @click="axis.zoomOut" />
|
||||||
|
|
||||||
<!-- skip :defaultValue="axis.default.discrete.value" because snapping makes dragging to negative values impossible -->
|
<!-- skip :defaultValue="axis.default.discrete.value" because snapping makes dragging to negative values impossible -->
|
||||||
<Slider :min="axis.min.discrete.value" :max="axis.max.discrete.value" :step="axis.stepSmall.discrete.value"
|
<Slider
|
||||||
v-model.number="axis.zoom.discrete.value" :orientation :reset="axis.reset" />
|
v-model.number="axis.zoom.discrete.value"
|
||||||
|
:min="axis.min.discrete.value"
|
||||||
|
:max="axis.max.discrete.value"
|
||||||
|
:step="axis.stepSmall.discrete.value"
|
||||||
|
:orientation
|
||||||
|
:reset="axis.reset"
|
||||||
|
/>
|
||||||
|
|
||||||
<ToolButtonSmall :icon="Add" title="Zoom In" @click="axis.zoomIn" :disabled="axis.isAtMax.value" />
|
<ToolButtonSmall :icon="Add" title="Zoom In" :disabled="axis.isAtMax.value" @click="axis.zoomIn" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ToolBar from "./ToolBar.vue";
|
import ToolBar from './ToolBar.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:h-full tw:bg-(--main-background-color) tw:border-(--view-separator-color) tw:flex tw:flex-col">
|
<div class="tw:h-full tw:bg-(--main-background-color) tw:border-(--view-separator-color) tw:flex tw:flex-col">
|
||||||
|
|
||||||
<ToolBar v-if="$slots.toolbar">
|
<ToolBar v-if="$slots.toolbar">
|
||||||
<slot name="toolbar" />
|
<slot name="toolbar" />
|
||||||
</ToolBar>
|
</ToolBar>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Panel from "./Panel.vue";
|
import Panel from './Panel.vue'
|
||||||
import ShadowedScrollView from "./ShadowedScrollView.vue";
|
import ShadowedScrollView from './ShadowedScrollView.vue'
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useInterval, useScroll } from "@vueuse/core";
|
import { useScroll } from '@vueuse/core'
|
||||||
import { useTemplateRef } from "vue";
|
import { useTemplateRef } from 'vue'
|
||||||
|
|
||||||
const scrollView = useTemplateRef('scrollView');
|
const scrollView = useTemplateRef('scrollView')
|
||||||
|
|
||||||
const { arrivedState, measure } = useScroll(scrollView);
|
const { arrivedState } = useScroll(scrollView)
|
||||||
|
|
||||||
// useScroll.arrivedState can get stale,
|
// useScroll.arrivedState can get stale,
|
||||||
// see: https://github.com/vueuse/vueuse/issues/4265#issuecomment-3618168624
|
// see: https://github.com/vueuse/vueuse/issues/4265#issuecomment-3618168624
|
||||||
|
// const { arrivedState, measure } = useScroll(scrollView)
|
||||||
// useInterval(2000, {
|
// useInterval(2000, {
|
||||||
// callback: () => {
|
// callback: () => {
|
||||||
// console.log("MEASURE");
|
// console.log("MEASURE");
|
||||||
|
|
@ -25,25 +26,24 @@ const { arrivedState, measure } = useScroll(scrollView);
|
||||||
|
|
||||||
<!-- bars of scroll shadow, on top of content -->
|
<!-- bars of scroll shadow, on top of content -->
|
||||||
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1; grid-column: 1;">
|
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1; grid-column: 1;">
|
||||||
|
|
||||||
<!-- top shadow -->
|
<!-- top shadow -->
|
||||||
<div class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.top }">
|
<div class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.top }">
|
||||||
<div class="tw:h-4 tw:w-full shadow-bottom"></div>
|
<div class="tw:h-4 tw:w-full shadow-bottom" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- bottom shadow -->
|
<!-- bottom shadow -->
|
||||||
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.bottom }">
|
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.bottom }">
|
||||||
<div class="tw:h-4 tw:w-full shadow-top"></div>
|
<div class="tw:h-4 tw:w-full shadow-top" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- left shadow -->
|
<!-- left shadow -->
|
||||||
<div class="tw:absolute tw:left-0 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.left }">
|
<div class="tw:absolute tw:left-0 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.left }">
|
||||||
<div class="tw:w-4 tw:h-full shadow-right"></div>
|
<div class="tw:w-4 tw:h-full shadow-right" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- right shadow -->
|
<!-- right shadow -->
|
||||||
<div class="tw:absolute tw:right-4 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.right }">
|
<div class="tw:absolute tw:right-4 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.right }">
|
||||||
<div class="tw:w-4 tw:h-full shadow-left"></div>
|
<div class="tw:w-4 tw:h-full shadow-left" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,48 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import mitt, { type Handler } from "mitt";
|
import type { Handler } from 'mitt'
|
||||||
import { useRafFn } from "@vueuse/core";
|
import { useRafFn } from '@vueuse/core'
|
||||||
import { onMounted, onBeforeUnmount } from "vue";
|
import mitt from 'mitt'
|
||||||
|
// eslint-disable-next-line import/no-duplicates
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
|
||||||
interface ScrollSyncEvent {
|
interface ScrollSyncEvent {
|
||||||
scrollTop: number;
|
scrollTop: number
|
||||||
scrollHeight: number;
|
scrollHeight: number
|
||||||
clientHeight: number;
|
clientHeight: number
|
||||||
scrollLeft: number;
|
scrollLeft: number
|
||||||
scrollWidth: number;
|
scrollWidth: number
|
||||||
clientWidth: number;
|
clientWidth: number
|
||||||
barHeight: number;
|
barHeight: number
|
||||||
barWidth: number;
|
barWidth: number
|
||||||
emitter: string;
|
emitter: string
|
||||||
group: string;
|
group: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Events = {
|
type Events = {
|
||||||
"scroll-sync": ScrollSyncEvent,
|
'scroll-sync': ScrollSyncEvent
|
||||||
};
|
}
|
||||||
|
|
||||||
const emitter = mitt<Events>();
|
const emitter = mitt<Events>()
|
||||||
|
|
||||||
function useEvent<Key extends keyof Events>(
|
function useEvent<Key extends keyof Events>(
|
||||||
type: Key,
|
type: Key,
|
||||||
handler: Handler<Events[Key]>,
|
handler: Handler<Events[Key]>,
|
||||||
): void {
|
): void {
|
||||||
const { on, off } = emitter;
|
const { on, off } = emitter
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
on(type, handler);
|
on(type, handler)
|
||||||
});
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
off(type, handler);
|
off(type, handler)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useId, useTemplateRef } from "vue";
|
// eslint-disable-next-line import/first, import/no-duplicates
|
||||||
|
import { useId, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
proportional,
|
proportional,
|
||||||
|
|
@ -46,18 +50,18 @@ const {
|
||||||
horizontal,
|
horizontal,
|
||||||
group,
|
group,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
proportional?: boolean,
|
proportional?: boolean
|
||||||
vertical?: boolean,
|
vertical?: boolean
|
||||||
horizontal?: boolean,
|
horizontal?: boolean
|
||||||
group: string,
|
group: string
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const uuid = useId();
|
const uuid = useId()
|
||||||
const nodeRef = useTemplateRef("scroll-sync-container");
|
const nodeRef = useTemplateRef('scroll-sync-container')
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollTo: (options: ScrollToOptions) => {
|
scrollTo: (options: ScrollToOptions) => {
|
||||||
nodeRef.value?.scrollTo(options);
|
nodeRef.value?.scrollTo(options)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -71,8 +75,8 @@ function handleScroll(event: Event) {
|
||||||
scrollWidth,
|
scrollWidth,
|
||||||
clientWidth,
|
clientWidth,
|
||||||
offsetHeight,
|
offsetHeight,
|
||||||
offsetWidth
|
offsetWidth,
|
||||||
} = event.target as HTMLElement;
|
} = event.target as HTMLElement
|
||||||
|
|
||||||
emitter.emit('scroll-sync', {
|
emitter.emit('scroll-sync', {
|
||||||
scrollTop,
|
scrollTop,
|
||||||
|
|
@ -85,15 +89,15 @@ function handleScroll(event: Event) {
|
||||||
barWidth: offsetWidth - clientWidth,
|
barWidth: offsetWidth - clientWidth,
|
||||||
emitter: uuid,
|
emitter: uuid,
|
||||||
group,
|
group,
|
||||||
});
|
})
|
||||||
}, { once: true });
|
}, { once: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
useEvent("scroll-sync", (event: ScrollSyncEvent) => {
|
useEvent('scroll-sync', (event: ScrollSyncEvent) => {
|
||||||
const node = nodeRef.value;
|
const node = nodeRef.value
|
||||||
|
|
||||||
if (event.group !== group || event.emitter === uuid || node === null) {
|
if (event.group !== group || event.emitter === uuid || node === null) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -104,40 +108,42 @@ useEvent("scroll-sync", (event: ScrollSyncEvent) => {
|
||||||
scrollWidth,
|
scrollWidth,
|
||||||
clientWidth,
|
clientWidth,
|
||||||
barHeight,
|
barHeight,
|
||||||
barWidth
|
barWidth,
|
||||||
} = event;
|
} = event
|
||||||
|
|
||||||
// from https://github.com/okonet/react-scroll-sync
|
// from https://github.com/okonet/react-scroll-sync
|
||||||
const scrollTopOffset = scrollHeight - clientHeight;
|
const scrollTopOffset = scrollHeight - clientHeight
|
||||||
const scrollLeftOffset = scrollWidth - clientWidth;
|
const scrollLeftOffset = scrollWidth - clientWidth
|
||||||
|
|
||||||
/* Calculate the actual pane height */
|
/* Calculate the actual pane height */
|
||||||
const paneHeight = node.scrollHeight - clientHeight;
|
const paneHeight = node.scrollHeight - clientHeight
|
||||||
const paneWidth = node.scrollWidth - clientWidth;
|
const paneWidth = node.scrollWidth - clientWidth
|
||||||
|
|
||||||
/* Adjust the scrollTop position of it accordingly */
|
/* Adjust the scrollTop position of it accordingly */
|
||||||
node.removeEventListener("scroll", handleScroll);
|
node.removeEventListener('scroll', handleScroll)
|
||||||
if (vertical && scrollTopOffset > barHeight) {
|
if (vertical && scrollTopOffset > barHeight) {
|
||||||
node.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop;
|
node.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop
|
||||||
}
|
}
|
||||||
if (horizontal && scrollLeftOffset > barWidth) {
|
if (horizontal && scrollLeftOffset > barWidth) {
|
||||||
node.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft;
|
node.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft
|
||||||
}
|
}
|
||||||
useRafFn(() => {
|
useRafFn(() => {
|
||||||
node.addEventListener("scroll", handleScroll);
|
node.addEventListener('scroll', handleScroll)
|
||||||
}, { once: true });
|
}, { once: true })
|
||||||
});
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const node = nodeRef.value;
|
const node = nodeRef.value
|
||||||
node!.addEventListener("scroll", handleScroll);
|
node!.addEventListener('scroll', handleScroll)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="scroll-sync-container" class="scroll-sync-container">
|
<div ref="scroll-sync-container" class="scroll-sync-container">
|
||||||
<slot></slot>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.scroll-sync-container {
|
.scroll-sync-container {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,32 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import VolumeSlider from '@/components/library/VolumeSlider.vue';
|
import { computed } from 'vue'
|
||||||
import { useTrackStore } from '@/store/TrackStore';
|
import VolumeSlider from '@/components/library/VolumeSlider.vue'
|
||||||
import { computed } from 'vue';
|
import { useTrackStore } from '@/store/TrackStore'
|
||||||
|
|
||||||
const trackStore = useTrackStore();
|
const trackStore = useTrackStore()
|
||||||
|
|
||||||
const muted = computed<boolean>({
|
const muted = computed<boolean>({
|
||||||
get() {
|
get() {
|
||||||
return trackStore.muted;
|
return trackStore.muted
|
||||||
},
|
},
|
||||||
set(muted: boolean) {
|
set(muted: boolean) {
|
||||||
trackStore.setMuted(muted);
|
trackStore.setMuted(muted)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const volume = computed<number>({
|
const volume = computed<number>({
|
||||||
get() {
|
get() {
|
||||||
return trackStore.volume;
|
return trackStore.volume
|
||||||
},
|
},
|
||||||
set(volume: number) {
|
set(volume: number) {
|
||||||
trackStore.setVolume(volume);
|
trackStore.setVolume(volume)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VolumeSlider v-model:muted="muted" v-model:volume="volume" />
|
<VolumeSlider v-model:muted="muted" v-model:volume="volume" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
positionSeconds,
|
positionSeconds,
|
||||||
|
|
@ -10,24 +8,30 @@ const {
|
||||||
hidden = false,
|
hidden = false,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
// position in absolute seconds
|
// position in absolute seconds
|
||||||
positionSeconds: number,
|
positionSeconds: number
|
||||||
knob?: boolean,
|
knob?: boolean
|
||||||
hidden?: boolean,
|
hidden?: boolean
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const positionPixels = computed(() => timeline.secondsToPixels(positionSeconds));
|
const timeline = useTimelineStore()
|
||||||
const viewportSide = computed(() => timeline.viewportSide(positionSeconds));
|
|
||||||
|
const positionPixels = computed(() => timeline.secondsToPixels(positionSeconds))
|
||||||
|
const viewportSide = computed(() => timeline.viewportSide(positionSeconds))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="playhead" :style="{
|
<div
|
||||||
'transform': `translateX(${positionPixels}px)`,
|
class="playhead" :style="{
|
||||||
'visibility': hidden ? 'hidden' : undefined,
|
transform: `translateX(${positionPixels}px)`,
|
||||||
}">
|
visibility: hidden ? 'hidden' : undefined,
|
||||||
<div class="tw:absolute tw:flex tw:flex-col tw:h-full" style="width: 17px; transform: translateX(-8px)"
|
}"
|
||||||
:style="{ paddingTop: knob ? 0 : 0 /* '1px' */ }">
|
>
|
||||||
<img src="@/assets/playhead-top.png" class="tw:flex-none" v-if="knob" />
|
<div
|
||||||
<img src="@/assets/playhead-main.png" class="tw:flex-1" />
|
class="tw:absolute tw:flex tw:flex-col tw:h-full" style="width: 17px; transform: translateX(-8px)"
|
||||||
|
:style="{ paddingTop: knob ? 0 : 0 /* '1px' */ }"
|
||||||
|
>
|
||||||
|
<img v-if="knob" src="@/assets/playhead-top.png" class="tw:flex-none">
|
||||||
|
<img src="@/assets/playhead-main.png" class="tw:flex-1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- slot container -->
|
<!-- slot container -->
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,108 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ZoomSlider from '@/components/library/ZoomSlider.vue';
|
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState'
|
||||||
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
|
import type { UseZoomAxis } from '@/lib/useZoomAxis'
|
||||||
import Playhead from '@/components/timeline/Playhead.vue';
|
import { useElementBounding, useScroll } from '@vueuse/core'
|
||||||
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
|
import { storeToRefs } from 'pinia'
|
||||||
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
|
import { useId, useTemplateRef, watch } from 'vue'
|
||||||
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
|
import ZoomSlider from '@/components/library/ZoomSlider.vue'
|
||||||
import { useTimelineScrubbing } from "@/lib/useTimelineScrubbing";
|
import ScrollSync from '@/components/scrollsync/ScrollSync.vue'
|
||||||
import { useVeiwportWheel } from '@/lib/useVeiwportWheel';
|
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue'
|
||||||
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
import Playhead from '@/components/timeline/Playhead.vue'
|
||||||
import { bindTwoWay, toPx } from '@/lib/vue';
|
import { onInputKeyStroke } from '@/lib/onInputKeyStroke'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useTimelineScrubbing } from '@/lib/useTimelineScrubbing'
|
||||||
import { useElementBounding, useScroll } from '@vueuse/core';
|
import { useVeiwportWheel } from '@/lib/useVeiwportWheel'
|
||||||
import { storeToRefs } from 'pinia';
|
import { bindTwoWay, toPx } from '@/lib/vue'
|
||||||
import { useId, useTemplateRef, watch } from 'vue';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import TimelineTrackHeader from './TimelineTrackHeader.vue';
|
import TimelineMarkers from './markers/TimelineMarkers.vue'
|
||||||
import TimelineTrackView from './TimelineTrackView.vue';
|
import TimelineTrackHeader from './TimelineTrackHeader.vue'
|
||||||
import TimelineMarkers from './markers/TimelineMarkers.vue';
|
import TimelineTrackView from './TimelineTrackView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
rightSidebar,
|
rightSidebar,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
rightSidebar: UseOptionalWidgetStateReturn,
|
rightSidebar: UseOptionalWidgetStateReturn
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
headerHeight, sidebarWidth,
|
headerHeight,
|
||||||
viewportScrollOffsetTop, viewportScrollOffsetLeft,
|
sidebarWidth,
|
||||||
|
viewportScrollOffsetTop,
|
||||||
|
viewportScrollOffsetLeft,
|
||||||
contentWidthIncludingEmptySpacePx,
|
contentWidthIncludingEmptySpacePx,
|
||||||
visibleTracks,
|
visibleTracks,
|
||||||
} = storeToRefs(timeline);
|
} = storeToRefs(timeline)
|
||||||
// nested composable marked with markRaw
|
// nested composable marked with markRaw
|
||||||
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis;
|
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis
|
||||||
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis;
|
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
|
||||||
|
|
||||||
const timelineScrollGroup = useId();
|
const timelineScrollGroup = useId()
|
||||||
|
|
||||||
const timelineRootElement = useTemplateRef('timelineRootElement');
|
const timelineRootElement = useTemplateRef('timelineRootElement')
|
||||||
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView');
|
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView')
|
||||||
const timelineScrollViewBounding = useElementBounding(timelineScrollView);
|
const timelineScrollViewBounding = useElementBounding(timelineScrollView)
|
||||||
watch(timelineScrollViewBounding.width, (value) => {
|
watch(timelineScrollViewBounding.width, (value) => {
|
||||||
timeline.viewportWidth = value;
|
timeline.viewportWidth = value
|
||||||
});
|
})
|
||||||
watch(timelineScrollViewBounding.height, (value) => {
|
watch(timelineScrollViewBounding.height, (value) => {
|
||||||
timeline.viewportHeight = value;
|
timeline.viewportHeight = value
|
||||||
});
|
})
|
||||||
const {
|
const {
|
||||||
arrivedState: timelineScrollViewArrivedState,
|
arrivedState: timelineScrollViewArrivedState,
|
||||||
x: timelineScrollViewOffsetLeft,
|
x: timelineScrollViewOffsetLeft,
|
||||||
y: timelineScrollViewOffsetTop,
|
y: timelineScrollViewOffsetTop,
|
||||||
} = useScroll(() => timelineScrollView.value?.$el);
|
} = useScroll(() => timelineScrollView.value?.$el)
|
||||||
|
|
||||||
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop);
|
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop)
|
||||||
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft);
|
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft)
|
||||||
|
|
||||||
useVeiwportWheel(timelineRootElement, {
|
useVeiwportWheel(timelineRootElement, {
|
||||||
axisHorizontal: viewportZoomHorizontal,
|
axisHorizontal: viewportZoomHorizontal,
|
||||||
axisVertical: viewportZoomVertical,
|
axisVertical: viewportZoomVertical,
|
||||||
scrollOffsetLeft: timelineScrollViewOffsetLeft,
|
scrollOffsetLeft: timelineScrollViewOffsetLeft,
|
||||||
});
|
})
|
||||||
|
|
||||||
// Shift+Z - reset zoom
|
// Shift+Z - reset zoom
|
||||||
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
|
onInputKeyStroke(event => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
|
||||||
timeline.zoomToggleBetweenWholeAndLoop();
|
timeline.zoomToggleBetweenWholeAndLoop()
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
});
|
})
|
||||||
|
|
||||||
const scrubbing = useTemplateRef('scrubbing');
|
const scrubbing = useTemplateRef('scrubbing')
|
||||||
useTimelineScrubbing(scrubbing);
|
useTimelineScrubbing(scrubbing)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
|
<div
|
||||||
|
ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
|
||||||
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
|
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
|
||||||
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
|
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
|
||||||
}">
|
}"
|
||||||
|
>
|
||||||
<!-- top left corner, contains zoom controls -->
|
<!-- top left corner, contains zoom controls -->
|
||||||
<div class="toolbar-background tw:max-w-full tw:flex tw:flex-row tw:flex-nowrap tw:items-center"
|
<div
|
||||||
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);">
|
class="toolbar-background tw:max-w-full tw:flex tw:flex-row tw:flex-nowrap tw:items-center"
|
||||||
|
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);"
|
||||||
|
>
|
||||||
<ZoomSlider :axis="viewportZoomHorizontal" class="tw:flex-1" />
|
<ZoomSlider :axis="viewportZoomHorizontal" class="tw:flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- left sidebar with timeline track names -->
|
<!-- left sidebar with timeline track names -->
|
||||||
<ScrollSync :group="timelineScrollGroup" :vertical="true" class="toolbar-background scrollbar-none"
|
<ScrollSync
|
||||||
style="grid-row: 2; grid-column: 1; border-right: var(--view-separator-border);">
|
:group="timelineScrollGroup" :vertical="true" class="toolbar-background scrollbar-none"
|
||||||
|
style="grid-row: 2; grid-column: 1; border-right: var(--view-separator-border);"
|
||||||
<template v-for="timelineTrack in visibleTracks">
|
>
|
||||||
<TimelineTrackHeader :timelineTrack />
|
<template v-for="timelineTrack in visibleTracks" :key="timelineTrack.name">
|
||||||
|
<TimelineTrackHeader :timeline-track />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</ScrollSync>
|
</ScrollSync>
|
||||||
|
|
||||||
|
|
||||||
<!-- header with timestamps -->
|
<!-- header with timestamps -->
|
||||||
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
|
<ScrollSync
|
||||||
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);">
|
:group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
|
||||||
|
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);"
|
||||||
|
>
|
||||||
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
|
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
|
||||||
<TimelineHeader />
|
<TimelineHeader />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,63 +110,63 @@ useTimelineScrubbing(scrubbing);
|
||||||
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
|
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
|
||||||
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
||||||
<!-- </Playhead> -->
|
<!-- </Playhead> -->
|
||||||
|
|
||||||
</ScrollSync>
|
</ScrollSync>
|
||||||
|
|
||||||
<!-- timeline content -->
|
<!-- timeline content -->
|
||||||
<ScrollSync ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
|
<ScrollSync
|
||||||
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;">
|
ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
|
||||||
|
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;"
|
||||||
|
>
|
||||||
<!-- timeline content wrapper for good measure -->
|
<!-- timeline content wrapper for good measure -->
|
||||||
<div class="tw:relative tw:overflow-hidden tw:min-h-full"
|
<div
|
||||||
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }">
|
class="tw:relative tw:overflow-hidden tw:min-h-full"
|
||||||
|
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }"
|
||||||
|
>
|
||||||
<!-- timeline markers -->
|
<!-- timeline markers -->
|
||||||
<TimelineMarkers />
|
<TimelineMarkers />
|
||||||
|
|
||||||
<!-- timeline tracks -->
|
<!-- timeline tracks -->
|
||||||
<div>
|
<div>
|
||||||
<template v-for="timelineTrack in visibleTracks">
|
<template v-for="timelineTrack in visibleTracks" :key="timelineTrack.name">
|
||||||
<TimelineTrackView :timelineTrack />
|
<TimelineTrackView :timeline-track />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ScrollSync>
|
</ScrollSync>
|
||||||
|
|
||||||
|
|
||||||
<!-- horizontal bars of scroll shadow, on top of sidebar and content, but under playhead -->
|
<!-- horizontal bars of scroll shadow, on top of sidebar and content, but under playhead -->
|
||||||
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 2; grid-column: 1 / 3;">
|
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 2; grid-column: 1 / 3;">
|
||||||
<div class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full"
|
<div
|
||||||
:class="{ 'tw:invisible': timelineScrollViewArrivedState.top }">
|
class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full"
|
||||||
<div class="tw:h-4 tw:w-full shadow-bottom"></div>
|
:class="{ 'tw:invisible': timelineScrollViewArrivedState.top }"
|
||||||
|
>
|
||||||
|
<div class="tw:h-4 tw:w-full shadow-bottom" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full"
|
<div
|
||||||
:class="{ 'tw:invisible': timelineScrollViewArrivedState.bottom }">
|
class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full"
|
||||||
<div class="tw:h-4 tw:w-full shadow-top"></div>
|
:class="{ 'tw:invisible': timelineScrollViewArrivedState.bottom }"
|
||||||
|
>
|
||||||
|
<div class="tw:h-4 tw:w-full shadow-top" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- playhead -->
|
<!-- playhead -->
|
||||||
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
|
<ScrollSync
|
||||||
style="grid-row: 1 / 3; grid-column: 2;">
|
:group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
|
||||||
|
style="grid-row: 1 / 3; grid-column: 2;"
|
||||||
<div class="tw:h-full tw:relative tw:overflow-hidden"
|
>
|
||||||
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }">
|
<div
|
||||||
|
class="tw:h-full tw:relative tw:overflow-hidden"
|
||||||
|
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }"
|
||||||
|
>
|
||||||
<!-- actuals playback position -->
|
<!-- actuals playback position -->
|
||||||
<Playhead :positionSeconds="timeline.playheadPosition" :knob="true">
|
<Playhead :position-seconds="timeline.playheadPosition" :knob="true">
|
||||||
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
||||||
</Playhead>
|
</Playhead>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ScrollSync>
|
</ScrollSync>
|
||||||
|
|
||||||
|
|
||||||
<!-- cursor on hover -->
|
<!-- cursor on hover -->
|
||||||
<!-- <Playhead :position="cursorPosition" :timelineWidth="timelineWidth" :knob="false"
|
<!-- <Playhead :position="cursorPosition" :timelineWidth="timelineWidth" :knob="false"
|
||||||
:hidden="cursorPositionSeconds === null || isDragging">
|
:hidden="cursorPositionSeconds === null || isDragging">
|
||||||
|
|
@ -170,34 +175,38 @@ useTimelineScrubbing(scrubbing);
|
||||||
|
|
||||||
<!-- vertical bars of scroll shadow, on top of header, content AND playhead -->
|
<!-- vertical bars of scroll shadow, on top of header, content AND playhead -->
|
||||||
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1 / -1; grid-column: 2;">
|
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1 / -1; grid-column: 2;">
|
||||||
<div class="tw:absolute tw:top-0 tw:left-0 tw:w-0 tw:h-full"
|
<div
|
||||||
:class="{ 'tw:invisible': timelineScrollViewArrivedState.left }">
|
class="tw:absolute tw:top-0 tw:left-0 tw:w-0 tw:h-full"
|
||||||
<div class="tw:w-4 tw:h-full shadow-right"></div>
|
:class="{ 'tw:invisible': timelineScrollViewArrivedState.left }"
|
||||||
|
>
|
||||||
|
<div class="tw:w-4 tw:h-full shadow-right" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tw:absolute tw:top-0 tw:right-4 tw:w-0 tw:h-full"
|
<div
|
||||||
:class="{ 'tw:invisible': timelineScrollViewArrivedState.right }">
|
class="tw:absolute tw:top-0 tw:right-4 tw:w-0 tw:h-full"
|
||||||
<div class="tw:w-4 tw:h-full shadow-left"></div>
|
:class="{ 'tw:invisible': timelineScrollViewArrivedState.right }"
|
||||||
|
>
|
||||||
|
<div class="tw:w-4 tw:h-full shadow-left" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- empty cell at the top right -->
|
<!-- empty cell at the top right -->
|
||||||
<div v-if="rightSidebar.visible.value" class="toolbar-background"
|
<div
|
||||||
style="grid-row: 1; grid-column: 3; border-bottom: var(--view-separator-border); border-left: var(--view-separator-border);">
|
v-if="rightSidebar.visible.value" class="toolbar-background"
|
||||||
</div>
|
style="grid-row: 1; grid-column: 3; border-bottom: var(--view-separator-border); border-left: var(--view-separator-border);"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- right sidebar with vertical zoom slider -->
|
<!-- right sidebar with vertical zoom slider -->
|
||||||
<div v-if="rightSidebar.visible.value"
|
<div
|
||||||
|
v-if="rightSidebar.visible.value"
|
||||||
class="toolbar-background tw:size-full tw:min-h-0 tw:py-2 tw:flex tw:flex-col tw:items-center"
|
class="toolbar-background tw:size-full tw:min-h-0 tw:py-2 tw:flex tw:flex-col tw:items-center"
|
||||||
style="grid-row: 2; grid-column: 3; border-left: var(--view-separator-border);">
|
style="grid-row: 2; grid-column: 3; border-left: var(--view-separator-border);"
|
||||||
|
>
|
||||||
<ZoomSlider :axis="viewportZoomVertical" orientation="vertical" class="tw:w-full tw:min-h-0" />
|
<ZoomSlider :axis="viewportZoomVertical" orientation="vertical" class="tw:w-full tw:min-h-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shadow-top,
|
.shadow-top,
|
||||||
.shadow-right,
|
.shadow-right,
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,66 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { togglePlayStop } from '@/audio/AudioEngine';
|
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg'
|
||||||
import ToolButton from '@/components/library/ToolButton.vue';
|
import Play from '@material-design-icons/svg/outlined/play_circle.svg'
|
||||||
import ToolToggle from '@/components/library/ToolToggle.vue';
|
import Replay from '@material-design-icons/svg/outlined/replay.svg'
|
||||||
import Timestamp from '@/components/timeline/Timestamp.vue';
|
import Restart from '@material-design-icons/svg/outlined/restart_alt.svg'
|
||||||
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState';
|
import ViewSidebar from '@material-design-icons/svg/outlined/view_sidebar.svg'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg';
|
import { storeToRefs } from 'pinia'
|
||||||
import Play from '@material-design-icons/svg/outlined/play_circle.svg';
|
import { computed } from 'vue'
|
||||||
import Replay from '@material-design-icons/svg/outlined/replay.svg';
|
import { togglePlayStop } from '@/audio/AudioEngine'
|
||||||
import Restart from '@material-design-icons/svg/outlined/restart_alt.svg';
|
import Panel from '@/components/library/panel/Panel.vue'
|
||||||
import ViewSidebar from '@material-design-icons/svg/outlined/view_sidebar.svg';
|
import ToolButton from '@/components/library/ToolButton.vue'
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import ToolToggle from '@/components/library/ToolToggle.vue'
|
||||||
import { storeToRefs } from 'pinia';
|
import Timestamp from '@/components/timeline/Timestamp.vue'
|
||||||
import { computed } from 'vue';
|
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState'
|
||||||
import MasterVolumeSlider from './MasterVolumeSlider.vue';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import Timeline from './Timeline.vue';
|
import MasterVolumeSlider from './MasterVolumeSlider.vue'
|
||||||
import Panel from "@/components/library/panel/Panel.vue";
|
import Timeline from './Timeline.vue'
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore()
|
||||||
const { audioTrack, isPlaying } = storeToRefs(timeline);
|
const { audioTrack, isPlaying } = storeToRefs(timeline)
|
||||||
|
|
||||||
const hasLoopOffset = computed(() => audioTrack.value?.LoopOffset !== 0);
|
const hasLoopOffset = computed(() => audioTrack.value?.LoopOffset !== 0)
|
||||||
|
|
||||||
// Questionable thin vertical sidebar on the right, contains vertical zoom slider.
|
// Questionable thin vertical sidebar on the right, contains vertical zoom slider.
|
||||||
// Not sure I want this to remain, so used a boolean flag to hide.
|
// Not sure I want this to remain, so used a boolean flag to hide.
|
||||||
const rightSidebar = useOptionalWidgetState({
|
const rightSidebar = useOptionalWidgetState({
|
||||||
visible: useLocalStorage("timeline.rightSidebar.visible", true),
|
visible: useLocalStorage('timeline.rightSidebar.visible', true),
|
||||||
showString: "Show Right Sidebar",
|
showString: 'Show Right Sidebar',
|
||||||
hideString: "Hide Right Sidebar",
|
hideString: 'Hide Right Sidebar',
|
||||||
width: 32,
|
width: 32,
|
||||||
});
|
})
|
||||||
|
|
||||||
function rewindToIntro() {
|
function rewindToIntro() {
|
||||||
timeline.rewindToIntro();
|
timeline.rewindToIntro()
|
||||||
}
|
}
|
||||||
function rewindToWindUp() {
|
function rewindToWindUp() {
|
||||||
timeline.rewindToWindUp();
|
timeline.rewindToWindUp()
|
||||||
}
|
}
|
||||||
function rewindToLoop() {
|
function rewindToLoop() {
|
||||||
timeline.rewindToLoop();
|
timeline.rewindToLoop()
|
||||||
}
|
}
|
||||||
function toggle() {
|
function toggle() {
|
||||||
togglePlayStop(timeline.player, { rememberPosition: true });
|
togglePlayStop(timeline.player, { rememberPosition: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Panel class="tw:border-t">
|
<Panel class="tw:border-t">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<div
|
<div
|
||||||
class="tw:flex tw:flex-row tw:max-sm:flex-col tw:items-center tw:justify-center tw:gap-x-4 tw:gap-y-2 tw:px-4 tw:max-sm:px-2 tw:py-1">
|
class="tw:flex tw:flex-row tw:max-sm:flex-col tw:items-center tw:justify-center tw:gap-x-4 tw:gap-y-2 tw:px-4 tw:max-sm:px-2 tw:py-1"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:border-(--view-separator-color)">
|
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:border-(--view-separator-color)"
|
||||||
<ToolButton :icon="Replay" @click="rewindToIntro" title="Rewind to Intro" />
|
>
|
||||||
<ToolButton :icon="Restart" @click="rewindToWindUp"
|
<ToolButton :icon="Replay" title="Rewind to Intro" @click="rewindToIntro" />
|
||||||
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
|
<ToolButton
|
||||||
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
|
:icon="Restart"
|
||||||
|
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'"
|
||||||
|
@click="rewindToWindUp"
|
||||||
|
/>
|
||||||
|
<ToolButton v-if="hasLoopOffset" :icon="Restart" title="Rewind to Loop" @click="rewindToLoop" />
|
||||||
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'" @click="toggle" />
|
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'" @click="toggle" />
|
||||||
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
|
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -64,16 +70,18 @@ function toggle() {
|
||||||
{{ audioTrack?.Name }}
|
{{ audioTrack?.Name }}
|
||||||
</div>
|
</div>
|
||||||
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
|
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
|
||||||
<ToolToggle :checked="rightSidebar.visible.value" :icon="ViewSidebar" @click="rightSidebar.toggle()"
|
<ToolToggle
|
||||||
:title="rightSidebar.toggleActionString.value" />
|
:checked="rightSidebar.visible.value" :icon="ViewSidebar" :title="rightSidebar.toggleActionString.value"
|
||||||
|
@click="rightSidebar.toggle()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Timeline class="tw:min-h-0 tw:size-full" :rightSidebar />
|
<Timeline class="tw:min-h-0 tw:size-full" :right-sidebar />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.description {
|
.description {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,45 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { toPx } from '@/lib/vue';
|
import { storeToRefs } from 'pinia'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia';
|
import { toPx } from '@/lib/vue'
|
||||||
import { computed } from 'vue';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
timelineTrack,
|
timelineTrack,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
timelineTrack: TimelineTrackData,
|
timelineTrack: TimelineTrackData
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const { trackHeight } = storeToRefs(useTimelineStore());
|
const { trackHeight } = storeToRefs(useTimelineStore())
|
||||||
|
|
||||||
const enCardinalRules = new Intl.PluralRules("en-US");
|
const enCardinalRules = new Intl.PluralRules('en-US')
|
||||||
|
|
||||||
const clipStrings = new Map([
|
const clipStrings = new Map([
|
||||||
["zero", "Clips"],
|
['zero', 'Clips'],
|
||||||
["one", "Clip"],
|
['one', 'Clip'],
|
||||||
["two", "Clips"],
|
['two', 'Clips'],
|
||||||
["few", "Clips"],
|
['few', 'Clips'],
|
||||||
["other", "Clips"],
|
['other', 'Clips'],
|
||||||
]);
|
])
|
||||||
|
|
||||||
function getClipsCountString(n: number): string {
|
function getClipsCountString(n: number): string {
|
||||||
return `${n} ${clipStrings.get(enCardinalRules.select(n)) ?? "Clip"}`;
|
return `${n} ${clipStrings.get(enCardinalRules.select(n)) ?? 'Clip'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const big = computed(() => trackHeight.value > 50);
|
const big = computed(() => trackHeight.value > 50)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- border-bottom -->
|
<!-- border-bottom -->
|
||||||
<div class="tw:w-full" style="border-bottom: var(--view-separator-border);" :style="{ height: toPx(trackHeight) }">
|
<div class="tw:w-full" style="border-bottom: var(--view-separator-border);" :style="{ height: toPx(trackHeight) }">
|
||||||
|
|
||||||
<!-- horizontal layout -->
|
<!-- horizontal layout -->
|
||||||
<div class="tw:size-full tw:flex tw:flex-row">
|
<div class="tw:size-full tw:flex tw:flex-row">
|
||||||
|
|
||||||
<!-- left color strip -->
|
<!-- left color strip -->
|
||||||
<div class="tw:flex-none tw:w-1 tw:h-full tw:border-r" style="border-right: var(--view-separator-border);"
|
<div
|
||||||
:style="{ backgroundColor: timelineTrack.color }" />
|
class="tw:flex-none tw:w-1 tw:h-full tw:border-r" style="border-right: var(--view-separator-border);"
|
||||||
|
:style="{ backgroundColor: timelineTrack.color }"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- another cool dark border -->
|
<!-- another cool dark border -->
|
||||||
<div class="tw:flex-none tw:w-2 tw:h-full tw:border-r" style="border-right: var(--view-separator-border);" />
|
<div class="tw:flex-none tw:w-2 tw:h-full tw:border-r" style="border-right: var(--view-separator-border);" />
|
||||||
|
|
@ -55,4 +56,5 @@ const big = computed(() => trackHeight.value > 50);
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,39 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { toPx } from '@/lib/vue';
|
import { storeToRefs } from 'pinia'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { toPx } from '@/lib/vue'
|
||||||
import { storeToRefs } from 'pinia';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import TimelineClipView from './clip/TimelineClipView.vue';
|
import TimelineClipView from './clip/TimelineClipView.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
timelineTrack,
|
timelineTrack,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
timelineTrack: TimelineTrackData,
|
timelineTrack: TimelineTrackData
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
|
||||||
const { trackHeight, contentWidthIncludingEmptySpacePx } = storeToRefs(timeline);
|
|
||||||
|
|
||||||
|
const timeline = useTimelineStore()
|
||||||
|
const { trackHeight, contentWidthIncludingEmptySpacePx } = storeToRefs(timeline)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
|
||||||
<div style="position: relative; display: grid; border-bottom: var(--timeline-track-border);"
|
|
||||||
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(trackHeight) }">
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
style="position: relative; display: grid; border-bottom: var(--timeline-track-border);"
|
||||||
|
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(trackHeight) }"
|
||||||
|
>
|
||||||
<!-- top & bottom lines -->
|
<!-- top & bottom lines -->
|
||||||
<div class="tw:size-full" style="grid-row: 1; grid-column: 1;
|
<div
|
||||||
border-top: var(--timeline-track-border-top); border-bottom: var(--timeline-track-border-bottom);" />
|
class="tw:size-full" style="grid-row: 1; grid-column: 1;
|
||||||
|
border-top: var(--timeline-track-border-top); border-bottom: var(--timeline-track-border-bottom);"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- timeline track's clips -->
|
<!-- timeline track's clips -->
|
||||||
<div class="tw:size-full" style="grid-row: 1; grid-column: 1; position: relative;">
|
<div class="tw:size-full" style="grid-row: 1; grid-column: 1; position: relative;">
|
||||||
<template v-for="timelineClip in timelineTrack.clips">
|
<!-- TODO: use clip id -->
|
||||||
<TimelineClipView :timelineTrack :timelineClip />
|
<template v-for="timelineClip in timelineTrack.clips" :key="timelineClip.clipIn">
|
||||||
|
<TimelineClipView :timeline-track :timeline-clip />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatBeats, formatTime } from '@/lib/AudioTrack';
|
import { formatBeats, formatTime } from '@/lib/AudioTrack'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
seconds: number,
|
seconds: number
|
||||||
beats: number,
|
beats: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="timestamp">
|
<div class="timestamp">
|
||||||
<div title="seconds">{{ formatTime(seconds) }}</div>
|
<div title="seconds">
|
||||||
<div title="beats">{{ formatBeats(beats) }}</div>
|
{{ formatTime(seconds) }}
|
||||||
|
</div>
|
||||||
|
<div title="beats">
|
||||||
|
{{ formatBeats(beats) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,45 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { timelineClipColor, toAbsoluteDuration, toAbsoluteTime, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { toPx, usePx } from '@/lib/usePx';
|
import { useCssVar, useElementBounding } from '@vueuse/core'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { storeToRefs } from 'pinia'
|
||||||
import { useCssVar, useElementBounding } from '@vueuse/core';
|
import { computed, shallowRef, useTemplateRef } from 'vue'
|
||||||
import { storeToRefs } from 'pinia';
|
import { timelineClipColor, toAbsoluteDuration, toAbsoluteTime } from '@/lib/Timeline'
|
||||||
import { computed, shallowRef, useTemplateRef } from 'vue';
|
import { toPx, usePx } from '@/lib/usePx'
|
||||||
import { getComponentFor } from '.';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
|
import { getComponentFor } from '.'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
timelineTrack,
|
timelineTrack,
|
||||||
timelineClip,
|
timelineClip,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
timelineTrack: TimelineTrackData,
|
timelineTrack: TimelineTrackData
|
||||||
timelineClip: TimelineClipData,
|
timelineClip: TimelineClipData
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore()
|
||||||
const { audioTrack } = storeToRefs(timeline);
|
const { audioTrack } = storeToRefs(timeline)
|
||||||
|
|
||||||
const contentView = computed(() => getComponentFor(timelineTrack));
|
const contentView = computed(() => getComponentFor(timelineTrack))
|
||||||
|
|
||||||
const left = computed(() => {
|
const left = computed(() => {
|
||||||
const t = toAbsoluteTime(audioTrack.value, timelineTrack.reference, timelineClip.clipIn);
|
const t = toAbsoluteTime(audioTrack.value, timelineTrack.reference, timelineClip.clipIn)
|
||||||
const px = timeline.secondsToPixels(t);
|
const px = timeline.secondsToPixels(t)
|
||||||
return toPx(px);
|
return toPx(px)
|
||||||
});
|
})
|
||||||
|
|
||||||
const width = usePx(() => {
|
const width = usePx(() => {
|
||||||
const t = toAbsoluteDuration(audioTrack.value, timelineTrack.reference, timelineClip.duration);
|
const t = toAbsoluteDuration(audioTrack.value, timelineTrack.reference, timelineClip.duration)
|
||||||
const px = timeline.secondsToPixels(t);
|
const px = timeline.secondsToPixels(t)
|
||||||
return px;
|
return px
|
||||||
});
|
})
|
||||||
|
|
||||||
const autorepeat = computed(() => timelineClip.autorepeat);
|
const autorepeat = computed(() => timelineClip.autorepeat)
|
||||||
const color = computed(() => timelineClipColor(timelineTrack, timelineClip));
|
const color = computed(() => timelineClipColor(timelineTrack, timelineClip))
|
||||||
|
|
||||||
const isSelected = shallowRef(false);
|
const isSelected = shallowRef(false)
|
||||||
function selectClip() {
|
function selectClip() {
|
||||||
// TODO: make selection manager
|
// TODO: make selection manager
|
||||||
isSelected.value = !isSelected.value;
|
isSelected.value = !isSelected.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// style:
|
// style:
|
||||||
|
|
@ -53,33 +54,47 @@ function selectClip() {
|
||||||
// - if not selected, custom colored border
|
// - if not selected, custom colored border
|
||||||
// - if selected, red outline
|
// - if selected, red outline
|
||||||
/* NOTE: the following is "would do anything to avoid hardcoding 4px width limit" */
|
/* NOTE: the following is "would do anything to avoid hardcoding 4px width limit" */
|
||||||
const selectionRef = useTemplateRef('selection');
|
const selectionRef = useTemplateRef('selection')
|
||||||
const { width: selectionWidth } = useElementBounding(selectionRef);
|
const { width: selectionWidth } = useElementBounding(selectionRef)
|
||||||
const outlineSelectedWidth = useCssVar('--timeline-clip-outline-selected-width', selectionRef);
|
const outlineSelectedWidth = useCssVar('--timeline-clip-outline-selected-width', selectionRef)
|
||||||
const innerBorderVisible = computed(() => outlineSelectedWidth.value ? selectionWidth.value > 2 * parseInt(outlineSelectedWidth.value, 10) : false);
|
const innerBorderVisible = computed(() => outlineSelectedWidth.value ? selectionWidth.value > 2 * Number.parseInt(outlineSelectedWidth.value, 10) : false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div @click="selectClip"
|
<div
|
||||||
class="tw:absolute tw:h-full tw:border tw:rounded-(--timeline-clip-border-radius) tw:overflow-hidden" :style="{
|
class="tw:absolute tw:h-full tw:border tw:rounded-(--timeline-clip-border-radius) tw:overflow-hidden"
|
||||||
|
:style="{
|
||||||
left,
|
left,
|
||||||
width: width.string,
|
width: width.string,
|
||||||
maxWidth: width.string,
|
maxWidth: width.string,
|
||||||
borderColor: autorepeat ? 'transparent' : 'var(--timeline-clip-border-color)',
|
borderColor: autorepeat ? 'transparent' : 'var(--timeline-clip-border-color)',
|
||||||
}">
|
}"
|
||||||
|
@click="selectClip"
|
||||||
|
>
|
||||||
<!-- background color within outline borders -->
|
<!-- background color within outline borders -->
|
||||||
<div v-if="!autorepeat" class="tw:absolute tw:size-full" :style="{ backgroundColor: color }" />
|
<div
|
||||||
|
v-if="!autorepeat"
|
||||||
|
class="tw:absolute tw:size-full"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
/>
|
||||||
|
|
||||||
<component :is="contentView" :track="timelineTrack" :clip="timelineClip" :width="width.number" />
|
<component :is="contentView" :track="timelineTrack" :clip="timelineClip" :width="width.number" />
|
||||||
|
|
||||||
<!-- selection outline, above content -->
|
<!-- selection outline, above content -->
|
||||||
<div v-if="isSelected || autorepeat" ref="selection"
|
<div
|
||||||
|
v-if="isSelected || autorepeat" ref="selection"
|
||||||
class="tw:absolute tw:size-full tw:max-w-full tw:pointer-events-none tw:select-none"
|
class="tw:absolute tw:size-full tw:max-w-full tw:pointer-events-none tw:select-none"
|
||||||
:class="{ 'selection': isSelected, autorepeat }" :style="!isSelected ? { borderColor: color } : null">
|
:class="{ selection: isSelected, autorepeat }"
|
||||||
<div v-if="!autorepeat && innerBorderVisible" class="tw:absolute tw:size-full tw:max-w-full selection-inner" />
|
:style="!isSelected ? { borderColor: color } : null"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!autorepeat && innerBorderVisible"
|
||||||
|
class="tw:absolute tw:size-full tw:max-w-full selection-inner"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.selection {
|
.selection {
|
||||||
border: var(--timeline-clip-outline-selected);
|
border: var(--timeline-clip-outline-selected);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
import BottomLine from './BottomLine.vue';
|
import { timelineClipLabel } from '@/lib/Timeline'
|
||||||
import AudioWaveform from './AudioWaveform.vue';
|
import AudioWaveform from './AudioWaveform.vue'
|
||||||
|
import BottomLine from './BottomLine.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
track,
|
track,
|
||||||
clip,
|
clip,
|
||||||
width,
|
width,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData
|
||||||
width: number,
|
width: number
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const label = computed(() => timelineClipLabel(track, clip));
|
const label = computed(() => timelineClipLabel(track, clip))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- waveform -->
|
<!-- waveform -->
|
||||||
<div v-if="clip.audioBuffer !== undefined" class="waveform-wrapper">
|
<div v-if="clip.audioBuffer !== undefined" class="waveform-wrapper">
|
||||||
|
|
@ -32,6 +34,7 @@ const label = computed(() => timelineClipLabel(track, clip));
|
||||||
<!-- clip line -->
|
<!-- clip line -->
|
||||||
<BottomLine />
|
<BottomLine />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.waveform-wrapper {
|
.waveform-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,130 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useWaveform } from '@/audio/AudioWaveform';
|
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core'
|
||||||
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core';
|
import { shallowRef, useTemplateRef, watchEffect } from 'vue'
|
||||||
import { shallowRef, useTemplateRef, watchEffect } from 'vue';
|
import { useWaveform } from '@/audio/AudioWaveform'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buffer,
|
buffer,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
buffer: AudioBuffer,
|
buffer: AudioBuffer
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const canvas = useTemplateRef('canvas');
|
const canvas = useTemplateRef('canvas')
|
||||||
const canvasWidth = shallowRef(0);
|
const canvasWidth = shallowRef(0)
|
||||||
|
|
||||||
// TODO: only render what's visible on the timeline.
|
// TODO: only render what's visible on the timeline.
|
||||||
// Currently at max zoom canvas may exceed 32_000 px width which browser refuses to render.
|
// Currently at max zoom canvas may exceed 32_000 px width which browser refuses to render.
|
||||||
|
|
||||||
const waveform = useWaveform(() => buffer, canvasWidth);
|
const waveform = useWaveform(() => buffer, canvasWidth)
|
||||||
|
|
||||||
const resizeObserver: globalThis.ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => {
|
let peakHeights = new Uint32Array(0)
|
||||||
const c = unrefElement(canvas);
|
|
||||||
if (!c) return;
|
|
||||||
|
|
||||||
const ctx = c.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const entry = entries.filter(entry => entry.target === c)[0];
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
// get the size from the ResizeObserverEntry (contentRect) and handle
|
|
||||||
// devicePixelRatio so the canvas looks sharp on HiDPI screens
|
|
||||||
const rect = entry.contentRect || c.getBoundingClientRect();
|
|
||||||
const cssWidth = rect.width;
|
|
||||||
const cssHeight = rect.height;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
|
|
||||||
// set internal canvas size in device pixels
|
|
||||||
c.width = Math.max(1, Math.round(cssWidth * dpr));
|
|
||||||
c.height = Math.max(1, Math.round(cssHeight * dpr));
|
|
||||||
|
|
||||||
canvasWidth.value = c.width;
|
|
||||||
|
|
||||||
redraw(waveform.isDone.value, waveform.peaks.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let peakHeights = new Uint32Array(0);
|
|
||||||
|
|
||||||
const redraw = useThrottleFn((isDone: boolean, peaks: Float32Array) => {
|
const redraw = useThrottleFn((isDone: boolean, peaks: Float32Array) => {
|
||||||
const c = unrefElement(canvas);
|
const c = unrefElement(canvas)
|
||||||
if (!c) return;
|
if (!c) {
|
||||||
|
return
|
||||||
const ctx = c.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const width = c.width;
|
|
||||||
const halfHeight = Math.floor(c.height / 2);
|
|
||||||
|
|
||||||
if (peakHeights.length != width) {
|
|
||||||
peakHeights = new Uint32Array(width);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = 1.75;
|
const ctx = c.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = c.width
|
||||||
|
const halfHeight = Math.floor(c.height / 2)
|
||||||
|
|
||||||
|
if (peakHeights.length !== width) {
|
||||||
|
peakHeights = new Uint32Array(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = 1.75
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
// audio tracks are normalized to a peak -14 dBFS, so we need to stretch them up to take up reasonable space
|
// audio tracks are normalized to a peak -14 dBFS, so we need to stretch them up to take up reasonable space
|
||||||
const peakHeight = Math.min(1, (peaks[x] ?? 0) * scale);
|
const peakHeight = Math.min(1, (peaks[x] ?? 0) * scale)
|
||||||
const height = Math.round(peakHeight * halfHeight);
|
const height = Math.round(peakHeight * halfHeight)
|
||||||
peakHeights[x] = height;
|
peakHeights[x] = height
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.save();
|
ctx.save()
|
||||||
ctx.clearRect(0, 0, c.width, c.height);
|
ctx.clearRect(0, 0, c.width, c.height)
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffffd8";
|
ctx.fillStyle = '#ffffffd8'
|
||||||
ctx.strokeStyle = "transparent";
|
ctx.strokeStyle = 'transparent'
|
||||||
|
|
||||||
// fill first, slanted outline next
|
// fill first, slanted outline next
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
const height = peakHeights[x]!;
|
const height = peakHeights[x]!
|
||||||
// draw vertically centered
|
// draw vertically centered
|
||||||
const y = Math.round(halfHeight - height);
|
const y = Math.round(halfHeight - height)
|
||||||
ctx.fillRect(x, y, 1, height * 2);
|
ctx.fillRect(x, y, 1, height * 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// outline
|
// outline
|
||||||
ctx.fillStyle = "transparent";
|
ctx.fillStyle = 'transparent'
|
||||||
ctx.strokeStyle = "#00000080";
|
ctx.strokeStyle = '#00000080'
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath()
|
||||||
|
|
||||||
for (const sign of [-1, 1]) {
|
for (const sign of [-1, 1]) {
|
||||||
ctx.moveTo(0, peakHeights[0] ?? 0);
|
ctx.moveTo(0, peakHeights[0] ?? 0)
|
||||||
|
|
||||||
for (let x = 1; x < width; x += 1) {
|
for (let x = 1; x < width; x += 1) {
|
||||||
const height = peakHeights[x]!;
|
const height = peakHeights[x]!
|
||||||
const y = sign * height + halfHeight;
|
const y = sign * height + halfHeight
|
||||||
ctx.lineTo(x, y);
|
ctx.lineTo(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke()
|
||||||
|
|
||||||
// middle line
|
// middle line
|
||||||
ctx.fillStyle = "#a1a998";
|
ctx.fillStyle = '#a1a998'
|
||||||
ctx.fillRect(0, Math.round(halfHeight), c.width, 1);
|
ctx.fillRect(0, Math.round(halfHeight), c.width, 1)
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore()
|
||||||
}, 0);
|
}, 0)
|
||||||
|
|
||||||
useResizeObserver(canvas, resizeObserver);
|
const resizeObserver: globalThis.ResizeObserverCallback = (entries: ResizeObserverEntry[], _observer: ResizeObserver) => {
|
||||||
|
const c = unrefElement(canvas)
|
||||||
|
if (!c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = c.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = entries.filter(entry => entry.target === c)[0]
|
||||||
|
if (!entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the size from the ResizeObserverEntry (contentRect) and handle
|
||||||
|
// devicePixelRatio so the canvas looks sharp on HiDPI screens
|
||||||
|
const rect = entry.contentRect || c.getBoundingClientRect()
|
||||||
|
const cssWidth = rect.width
|
||||||
|
const cssHeight = rect.height
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
|
||||||
|
// set internal canvas size in device pixels
|
||||||
|
c.width = Math.max(1, Math.round(cssWidth * dpr))
|
||||||
|
c.height = Math.max(1, Math.round(cssHeight * dpr))
|
||||||
|
|
||||||
|
canvasWidth.value = c.width
|
||||||
|
|
||||||
|
redraw(waveform.isDone.value, waveform.peaks.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
useResizeObserver(canvas, resizeObserver)
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
redraw(waveform.isDone.value, waveform.peaks.value);
|
redraw(waveform.isDone.value, waveform.peaks.value)
|
||||||
}, { flush: 'sync' });
|
}, { flush: 'sync' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<canvas ref="canvas" class="tw:size-full">
|
<canvas ref="canvas" class="tw:size-full" />
|
||||||
</canvas>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
import BottomLine from './BottomLine.vue';
|
import { timelineClipLabel } from '@/lib/Timeline'
|
||||||
|
import BottomLine from './BottomLine.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
track,
|
track,
|
||||||
clip,
|
clip,
|
||||||
width,
|
width,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData
|
||||||
width: number,
|
width: number
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const label = computed(() => timelineClipLabel(track, clip));
|
const label = computed(() => timelineClipLabel(track, clip))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- clip label -->
|
<!-- clip label -->
|
||||||
<div class="label-wrapper">
|
<div class="label-wrapper">
|
||||||
|
|
@ -25,6 +27,7 @@ const label = computed(() => timelineClipLabel(track, clip));
|
||||||
<!-- clip line -->
|
<!-- clip line -->
|
||||||
<BottomLine />
|
<BottomLine />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.label-wrapper {
|
.label-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData
|
||||||
width: number,
|
width: number
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="view">
|
<div class="view">
|
||||||
Yahaha
|
Yahaha
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view {
|
.view {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
import Default from './Default.vue';
|
import Default from './Default.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
width,
|
width,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData
|
||||||
width: number,
|
width: number
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:w-full fade-out-gradient" />
|
<div class="tw:absolute tw:w-full fade-out-gradient" />
|
||||||
|
|
||||||
<Default :track :clip :width />
|
<Default :track :clip :width />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fade-out {
|
.fade-out {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clip,
|
clip,
|
||||||
width,
|
width,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData
|
||||||
width: number,
|
width: number
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const lyrics = computed(() => clip.name ?? "");
|
const lyrics = computed(() => clip.name ?? '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="lyrics-wrapper">
|
<div class="lyrics-wrapper">
|
||||||
<span class="lyrics-content" :style="{ display: width < 22 ? 'none' : undefined }" :title="lyrics">
|
<span class="lyrics-content" :style="{ display: width < 22 ? 'none' : undefined }" :title="lyrics">
|
||||||
|
|
@ -20,6 +21,7 @@ const lyrics = computed(() => clip.name ?? "");
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.lyrics-wrapper {
|
.lyrics-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,64 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
// import { toPx } from '@/lib/vue';
|
import { storeToRefs } from 'pinia'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import Default from './Default.vue';
|
import Default from './Default.vue'
|
||||||
import { computed, reactive, toRefs } from 'vue';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clip,
|
clip,
|
||||||
width,
|
width,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData
|
||||||
width: number,
|
width: number
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const { audioTrack } = storeToRefs(useTimelineStore());
|
const { audioTrack } = storeToRefs(useTimelineStore())
|
||||||
const ColorTransitionOut = computed(() => `${(audioTrack.value?.ColorTransitionOut ?? 0) * 100}%`);
|
const ColorTransitionOut = computed(() => `${(audioTrack.value?.ColorTransitionOut ?? 0) * 100}%`)
|
||||||
const ColorTransitionIn = computed(() => `${100 - (audioTrack.value?.ColorTransitionIn ?? 0) * 100}%`);
|
const ColorTransitionIn = computed(() => `${100 - (audioTrack.value?.ColorTransitionIn ?? 0) * 100}%`)
|
||||||
|
|
||||||
// TODO: shift by BeatsOffset, use new method for computing index into pallete
|
// TODO: shift by BeatsOffset, use new method for computing index into pallete
|
||||||
|
|
||||||
const colorsPrevNext = computed(() => {
|
const colorsPrevNext = computed(() => {
|
||||||
const palette = audioTrack.value?.Palette;
|
const palette = audioTrack.value?.Palette
|
||||||
if (palette !== undefined && palette.length > 0) {
|
if (palette !== undefined && palette.length > 0) {
|
||||||
const nextColorIndex = (clip.clipIn + 1) % palette.length;
|
const nextColorIndex = (clip.clipIn + 1) % palette.length
|
||||||
const prevColorIndex = (clip.clipIn - 1) % palette.length;
|
const prevColorIndex = (clip.clipIn - 1) % palette.length
|
||||||
const nextColor = palette[nextColorIndex];
|
const nextColor = palette[nextColorIndex]
|
||||||
const prevColor = palette[prevColorIndex];
|
const prevColor = palette[prevColorIndex]
|
||||||
return { prevColor, nextColor };
|
return { prevColor, nextColor }
|
||||||
}
|
}
|
||||||
return { prevColor: clip.color, nextColor: clip.color };
|
return { prevColor: clip.color, nextColor: clip.color }
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:w-full palette-gradient" :style="{
|
<div
|
||||||
|
class="tw:absolute tw:w-full palette-gradient" :style="{
|
||||||
// TODO: this is inaccurate w.r.t. In & Out duration. Also wasteful.
|
// TODO: this is inaccurate w.r.t. In & Out duration. Also wasteful.
|
||||||
left: `-50%`,
|
'left': `-50%`,
|
||||||
width: `200%`,
|
'width': `200%`,
|
||||||
'--color-prev': colorsPrevNext.prevColor,
|
'--color-prev': colorsPrevNext.prevColor,
|
||||||
'--color-curr': clip.color,
|
'--color-curr': clip.color,
|
||||||
'--color-next': colorsPrevNext.nextColor,
|
'--color-next': colorsPrevNext.nextColor,
|
||||||
'--color-transition-out': ColorTransitionOut,
|
'--color-transition-out': ColorTransitionOut,
|
||||||
'--color-transition-in': ColorTransitionIn,
|
'--color-transition-in': ColorTransitionIn,
|
||||||
}" />
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
|
<div
|
||||||
:style="{ left: ColorTransitionOut }" />
|
class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
|
||||||
<div class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
|
:style="{ left: ColorTransitionOut }"
|
||||||
:style="{ left: ColorTransitionIn }" />
|
/>
|
||||||
|
<div
|
||||||
|
class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
|
||||||
|
:style="{ left: ColorTransitionIn }"
|
||||||
|
/>
|
||||||
|
|
||||||
<Default :track :clip :width />
|
<Default :track :clip :width />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fade-out {
|
.fade-out {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
* @module components/timeline/clip/impl
|
* @module components/timeline/clip/impl
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as Audio } from "./Audio.vue";
|
export { default as Audio } from './Audio.vue'
|
||||||
export { default as Default } from "./Default.vue";
|
export { default as Default } from './Default.vue'
|
||||||
export { default as Empty } from "./Empty.vue";
|
export { default as Empty } from './Empty.vue'
|
||||||
export { default as FadeOut } from "./FadeOut.vue";
|
export { default as FadeOut } from './FadeOut.vue'
|
||||||
export { default as Lyrics } from "./Lyrics.vue";
|
export { default as Lyrics } from './Lyrics.vue'
|
||||||
export { default as Palette } from "./Palette.vue";
|
export { default as Palette } from './Palette.vue'
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
import type { TimelineClipData, TimelineTrackData } from "@/lib/Timeline";
|
import type { Component } from 'vue'
|
||||||
import type { Component } from "vue";
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||||
import { Audio, Default, FadeOut, Lyrics, Palette } from "./impl";
|
import { Audio, Default, FadeOut, Lyrics, Palette } from './impl'
|
||||||
|
|
||||||
export interface ClipContentViewProps {
|
export interface ClipContentViewProps {
|
||||||
track: TimelineTrackData;
|
track: TimelineTrackData
|
||||||
clip: TimelineClipData;
|
clip: TimelineClipData
|
||||||
width: number;
|
width: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClipContentViewComponent = Component<ClipContentViewProps>;
|
export type ClipContentViewComponent = Component<ClipContentViewProps>
|
||||||
|
|
||||||
export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent {
|
export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent {
|
||||||
switch (track.contentViewType) {
|
switch (track.contentViewType) {
|
||||||
case "audio":
|
case 'audio':
|
||||||
return Audio;
|
return Audio
|
||||||
case "event":
|
case 'event':
|
||||||
return Default;
|
return Default
|
||||||
case "fadeout":
|
case 'fadeout':
|
||||||
return FadeOut;
|
return FadeOut
|
||||||
case "palette":
|
case 'palette':
|
||||||
return Palette;
|
return Palette
|
||||||
case "text":
|
case 'text':
|
||||||
return Lyrics;
|
return Lyrics
|
||||||
case "curve":
|
case 'curve':
|
||||||
return Default;
|
return Default
|
||||||
default:
|
default:
|
||||||
return Default;
|
return Default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { toPx } from '@/lib/vue';
|
import { toPx } from '@/lib/vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
left,
|
left,
|
||||||
|
|
@ -7,29 +7,41 @@ const {
|
||||||
label,
|
label,
|
||||||
position,
|
position,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
left: number;
|
left: number
|
||||||
width: string;
|
width: string
|
||||||
label: string;
|
label: string
|
||||||
position: "top" | "bottom";
|
position: 'top' | 'bottom'
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:h-full tw:top-0" :class="position" :style="{
|
<div
|
||||||
|
class="tw:absolute tw:h-full tw:top-0" :class="position" :style="{
|
||||||
left: toPx(left),
|
left: toPx(left),
|
||||||
width,
|
width,
|
||||||
}">
|
}"
|
||||||
|
>
|
||||||
<div class="tick-major" />
|
<div class="tick-major" />
|
||||||
|
|
||||||
<div class="tick tick-medium" />
|
<div class="tick tick-medium" />
|
||||||
|
|
||||||
<div v-for="i in 8" class="tick tick-minor" :style="{ left: `${10 * (i < 5 ? i : i + 1)}%` }" />
|
<div
|
||||||
<div v-for="i in 10" class="tick tick-patch" :style="{ left: `${10 * i + 5}%` }" />
|
v-for="i in 8" :key="i"
|
||||||
|
class="tick tick-minor"
|
||||||
|
:style="{ left: `${10 * (i < 5 ? i : i + 1)}%` }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="i in 10" :key="i"
|
||||||
|
class="tick tick-patch"
|
||||||
|
:style="{ left: `${10 * i + 5}%` }"
|
||||||
|
/>
|
||||||
|
|
||||||
<span class="tw:absolute tw:left-2 tw:text-xs tw:text-gray-400 tw:select-none label">
|
<span class="tw:absolute tw:left-2 tw:text-xs tw:text-gray-400 tw:select-none label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tick-major {
|
.tick-major {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,49 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue';
|
import { storeToRefs } from 'pinia'
|
||||||
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks';
|
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue'
|
||||||
import { toPx } from '@/lib/vue';
|
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks'
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { toPx } from '@/lib/vue'
|
||||||
import { storeToRefs } from 'pinia';
|
import { useTimelineStore } from '@/store/TimelineStore'
|
||||||
import TickInterval from './TickInterval.vue';
|
import TickInterval from './TickInterval.vue'
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore()
|
||||||
const { contentWidthIncludingEmptySpacePx, headerHeight } = storeToRefs(timeline);
|
const { contentWidthIncludingEmptySpacePx, headerHeight } = storeToRefs(timeline)
|
||||||
|
|
||||||
const allTicks = [
|
const allTicks = [
|
||||||
{ ticks: useTimelineTicksSeconds(), position: "top" },
|
{ ticks: useTimelineTicksSeconds(), position: 'top' },
|
||||||
{ ticks: useTimelineTicksBeats(), position: "bottom" },
|
{ ticks: useTimelineTicksBeats(), position: 'bottom' },
|
||||||
] as const;
|
] as const
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
|
||||||
<div class="tw:absolute tw:max-h-full tw:overflow-hidden" style=""
|
|
||||||
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }">
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="tw:absolute tw:max-h-full tw:overflow-hidden"
|
||||||
|
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }"
|
||||||
|
>
|
||||||
<!-- header ticks for seconds and beats -->
|
<!-- header ticks for seconds and beats -->
|
||||||
<div class="tw:absolute tw:size-full" v-for="{ ticks, position } in allTicks">
|
<div
|
||||||
<TickInterval v-for="tick in ticks.ticks.value" :position :left="ticks.left(tick).value"
|
v-for="{ ticks, position } in allTicks"
|
||||||
:width="ticks.widthPx.value" :label="ticks.label(tick).value" />
|
:key="position"
|
||||||
|
class="tw:absolute tw:size-full"
|
||||||
|
>
|
||||||
|
<TickInterval
|
||||||
|
v-for="tick in ticks.ticks.value"
|
||||||
|
:key="tick"
|
||||||
|
:position
|
||||||
|
:left="ticks.left(tick).value"
|
||||||
|
:width="ticks.widthPx.value"
|
||||||
|
:label="ticks.label(tick).value"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tw:absolute tw:size-full tw:border-b tw:border-[#252525]"></div>
|
<div class="tw:absolute tw:size-full tw:border-b tw:border-[#252525]" />
|
||||||
|
|
||||||
<!-- header markers -->
|
<!-- header markers -->
|
||||||
<div class="tw:absolute tw:size-full">
|
<div class="tw:absolute tw:size-full">
|
||||||
<MarkerBox v-for="marker in timeline.markers" :marker />
|
<!-- TODO: use marker id -->
|
||||||
|
<MarkerBox v-for="marker in timeline.markers" :key="marker.markerIn" :marker />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||