1
0
Fork 0

Compare commits

..

33 Commits

Author SHA1 Message Date
ivan tkachenko d313108f1a WIP: Add frontend web app player & editor in Vue 3 + Vite
TODO:
- implement viewing & editing.
- Add links to deployment, and CHANGELOG.

style.css

package.json

vite config

.vscode

eslint use --cache

.vscode

add vite-css-modules

editorconfig

tsconfig and updated vue-tsc (fixes most of the type checking bugs)

fix last type errors

audiowaveform

gitignore ESLint

ESLint: ignore autogenerated JSON

lint:fix tsconfig and vite config

migrate icon generating script to TS

eslint src/lib/

eslint stores

eslint src/*.ts

eslint audio

pnpm update

update icon

eslint ahh

import new tracks json

instructions on jq codenames

codenames.json

fix styles broken by import order

eslint audio

app

error screen

footer

copyright year

global header

loading screen

transition

search field

preview

track info

inspector

control

controls

controls range

controls impl

controls index

eslint no-console off

AudioTrack view inspector

cards and sliders

more controls

master volume slider

playhead

library page

player page

timeline markers

timeline markers

header tick

timestamp

timeline clip index

clip empty

clip lyrics

clip palette

clip fadeout

clip default

import order

timeline

timeline panel

timeline track header

timeline trackview

clip view

clip audio

audio waveform

scrollsync

easy lints

eslint store

eslint no mutating props off

pnpm catalog off

add unhead dep

use head

eslint inspector

eslint easy minor stuff

eslint audiowaveform

easy fix

eslint use :key with v-for

fix audio waveforms

inspector makes more sense

season

remove debug

inspector
2026-01-14 02:57:09 +02:00
ivan tkachenko 835d69d2d0 Release v1337.9001.67 2026-01-13 22:50:25 +02:00
ivan tkachenko 4442daae53 README: Add link to the web player 2026-01-13 22:50:20 +02:00
ivan tkachenko cfff2b808a Tweak disco balls at the factory's start room
Remove all but one in the center and hang it lower.
I don't access to source assets of the bundle, and I have no idea if
reassembling it after the AssetReapper is gonna work.
2026-01-13 22:38:06 +02:00
ivan tkachenko b8accefff7 Ignore code lint IDE0305
Replacing expr.ToArray() with [..expr] doesn't simplify anything.
2026-01-13 21:43:53 +02:00
ivan tkachenko b8ef4d7937 Implement client-side playback with Vanilla Compat Mode 2026-01-13 21:43:52 +02:00
ivan tkachenko dcae12ab36 Add a "NEW" badge to the icon
People get confused why there are two variants of seemingly the same mod.

Use Magic Wand with Threshold 2, Spread 12%, Grow -8px and Feather 1px
to mask white background of the stamp; then duplicate background on top,
set blending to Screen and use the stamp itself as a mask for overlay.
2026-01-13 05:18:58 +02:00
ivan tkachenko ffa2e952c9 Add new track TwoFastTuFurious 2026-01-13 04:57:49 +02:00
ivan tkachenko e05c3b2471 Bump version 2026-01-13 03:18:55 +02:00
ivan tkachenko d59c5a20c1 Add Thunderstore config for automated uploading 2026-01-12 04:00:33 +02:00
ivan tkachenko b1d449cf02 Release v1337.9001.4 2026-01-12 03:22:14 +02:00
ivan tkachenko 3f06cc9aa6 Add new track PickUpSticks 2026-01-12 03:20:03 +02:00
ivan tkachenko a5659fcb09 README: Include a link to an upcoming HookahPlace mod 2026-01-11 16:15:18 +02:00
ivan tkachenko 6271a377bd README: Describe recently added tracks 2026-01-11 16:15:18 +02:00
ivan tkachenko a4cee92d00 Load audio clips on demand, implement cache
Reduces cold-boot memory usage by 400 MB for the current playlist of
58 audio files (27.8 MB).
2026-01-11 16:06:45 +02:00
ivan tkachenko f83f2a72ba Mark AudioClip as nullable 2026-01-11 03:17:49 +02:00
ivan tkachenko afb3e34e71 Implement seasonal content framework
to ensure that New Year's songs won't play in summer.
2026-01-11 02:53:53 +02:00
ivan tkachenko ebd7811b12 Avoid null dereference while reading seed in orbit 2026-01-11 02:13:19 +02:00
ivan tkachenko a64d671527 Add Config.ReduceVFXIntensity option 2026-01-11 00:12:21 +02:00
ivan tkachenko 7eaa5fce75 Add new track DiscoKapot 2026-01-10 23:47:39 +02:00
ivan tkachenko da86ca6a2d Add new track Paarden 2026-01-10 22:51:56 +02:00
ivan tkachenko c4c1919df6 Adjust lyrics for PWNED 2026-01-10 21:10:08 +02:00
ivan tkachenko 869d982b1e Remaster recently added track IkWilJe, rework visual effects 2026-01-10 21:07:15 +02:00
ivan tkachenko 10839ba22c fixup CHANGELOG 2026-01-10 19:45:28 +02:00
ivan tkachenko 398de3dc04 Bump version 2026-01-10 19:41:07 +02:00
ivan tkachenko 4f432968ef Release v1337.9001.3 2025-12-30 23:40:33 +02:00
ivan tkachenko 56cea50a65 add new track IkWilJe 2025-12-30 23:39:01 +02:00
ivan tkachenko 0d416c6f5a Release v1337.9001.2 2025-12-30 22:51:39 +02:00
ivan tkachenko c1d91839e4 add new track HighLow 2025-12-30 22:25:50 +02:00
ivan tkachenko 76189c6ad2 Update BepInEx.PluginInfoProps to version 2.x
2.x implements better namespacing.
2025-12-30 22:25:49 +02:00
ivan tkachenko b6f576d50d Include debug symbols, but strip sensitive source paths 2025-12-20 20:35:15 +02:00
ivan tkachenko a4ca1c86ec Save Harmony own instance in private static
That's how other mods do it. Might be useful to reload patches.
2025-12-19 23:40:46 +02:00
ivan tkachenko 38c9472cb1 Port logging to BepInEx ManualLogSource
- pros: free namespace by default
- cons: Debug level has to be enabled manually in BepInEx.cfg,
  specifically in the section named [Logging.Console]
2025-12-19 23:39:28 +02:00
165 changed files with 9570 additions and 3926 deletions

View File

@ -3,3 +3,6 @@
# IDE0290: Use primary constructor
# Primary constructors are far from perfect: they can't have readonly fields, while fields can be used anywhere in the class body.
csharp_style_prefer_primary_constructors = false
# IDE0305: Simplify collection initialization
dotnet_style_prefer_collection_expression = never

9
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

103
.vscode/settings.json vendored Normal file
View File

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

95
.vscode/tailwind.json vendored Normal file
View File

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

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

View File

@ -1,7 +1,29 @@
# 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

8
Frontend/.editorconfig Normal file
View File

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

6
Frontend/.gitignore vendored
View File

@ -44,6 +44,12 @@ test/core/html/
explainFiles.txt
.vitest-dump
# ESLint
.eslintcache
# Vite CSS Modules
*.module.css.d.ts
# Project assets
/public/MuzikaGromcheAudio/*
!/public/MuzikaGromcheAudio/.gitkeep

View File

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

View File

@ -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:
```sh
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.
### Run & test
First time setup:

2
Frontend/env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />

19
Frontend/eslint.config.ts Normal file
View File

@ -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,
}],
},
})

View File

@ -1,52 +1,62 @@
{
"name": "muzika-gromche-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.js",
"build": "npm run prebuild && vue-tsc -b && vite build",
"build": "run-p type-check prebuild \"build-only {@}\" --",
"prebuild": "tsx scripts/generate-icons.ts",
"build-only": "vite build",
"preview": "vite preview",
"test": "vitest",
"coverage": "vitest run --coverage",
"test:browser": "vitest"
"type-check": "vue-tsc --build",
"lint": "eslint . --cache",
"lint:fix": "eslint . --cache --fix"
},
"dependencies": {
"@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",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
"vue-router": "^4.6.4"
},
"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",
"@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",
"eslint": "~9.39.1",
"eslint-plugin-vue": "~10.5.1",
"sharp": "^0.33.5",
"eslint-plugin-format": "^1.1.0",
"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",
"prettier": "^3.7.4",
"sharp": "^0.33.5",
"tsx": "^4.21.0",
"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-svg-loader": "^5.1.0",
"vitest": "^4.0.15",
"vitest-browser-vue": "^2.0.1",
"vue-tsc": "^3.1.5"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
},
"onlyBuiltDependencies": [
"core-js",
"sharp"
]
"vue-tsc": "^3.1.8"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
catalogMode: manual
shellEmulator: true
trustPolicy: no-downgrade
onlyBuiltDependencies:
- esbuild
- sharp

View File

@ -43,6 +43,10 @@
"Artist": "Noize MC",
"Song": "Устрой дестрой"
},
"DiscoKapot": {
"Artist": "Дискотека Авария",
"Song": "Новогодняя"
},
"Durochka": {
"Artist": "Би-2",
"Song": "Дурочка"
@ -55,6 +59,14 @@
"Artist": "Город под подошвой",
"Song": "Oxxxymiron"
},
"HighLow": {
"Artist": "Nirvana",
"Song": "Smells Like Teen Spirit"
},
"IkWilJe": {
"Artist": "My Chemical Romance",
"Song": "All I Want for Christmas Is You"
},
"Kach": {
"Artist": "Black Eyed Peas",
"Song": "Pump It"
@ -71,10 +83,22 @@
"Artist": "One-Punch Man",
"Song": "Opening"
},
"Paarden": {
"Artist": "Элизиум",
"Song": "Три белых коня"
},
"Peretasovka": {
"Artist": "LMFAO",
"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": {
"Artist": "CYBEЯIA",
"Song": "Russian Hackers"
@ -91,6 +115,10 @@
"Artist": "Витас",
"Song": "Опера #2"
},
"TwoFastTuFurious": {
"Artist": "t.A.T.u.",
"Song": "Not Gonna Get Us / Нас не догонят"
},
"VseVZale": {
"Artist": "Дискотека Авария",
"Song": " Х.Х.Х.И.Р.Н.Р."

View File

@ -1,9 +1,10 @@
{
"version": "1337.9001.2",
"version": "1337.9001.67",
"tracks": [
{
"Name": "AttentionPls1",
"IsExplicit": true,
"Season": null,
"Language": "Russian",
"WindUpTimer": 39.19,
"Bpm": 97.8244247,
@ -68,6 +69,7 @@
{
"Name": "AttentionPls2",
"IsExplicit": true,
"Season": null,
"Language": "Russian",
"WindUpTimer": 39.19,
"Bpm": 97.8244247,
@ -132,6 +134,7 @@
{
"Name": "BbIXODaHET",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 40.85,
"Bpm": 84.82064,
@ -186,6 +189,7 @@
{
"Name": "BeefLiver1",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 39.35,
"Bpm": 124.999992,
@ -332,6 +336,7 @@
{
"Name": "BeefLiver3",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 39.35,
"Bpm": 124.999992,
@ -478,6 +483,7 @@
{
"Name": "BeefLiver4",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 31.68,
"Bpm": 124.999992,
@ -612,6 +618,7 @@
{
"Name": "Beha1",
"IsExplicit": true,
"Season": null,
"Language": "Russian",
"WindUpTimer": 35.23,
"Bpm": 81.99027,
@ -648,6 +655,7 @@
{
"Name": "Beha2",
"IsExplicit": true,
"Season": null,
"Language": "Russian",
"WindUpTimer": 38.16,
"Bpm": 81.99027,
@ -684,6 +692,7 @@
{
"Name": "Beha3",
"IsExplicit": true,
"Season": null,
"Language": "Russian",
"WindUpTimer": 35.21,
"Bpm": 81.99027,
@ -720,6 +729,7 @@
{
"Name": "Chereshnya",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 45.48,
"Bpm": 131.958755,
@ -769,6 +779,7 @@
{
"Name": "DeployDestroy",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 40.68,
"Bpm": 129.878922,
@ -928,9 +939,63 @@
],
"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",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 37.0,
"Bpm": 129.9686,
@ -968,6 +1033,7 @@
{
"Name": "GodMode",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 40.38,
"Bpm": 108.016876,
@ -1022,6 +1088,7 @@
{
"Name": "Gorgorod",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 43.2,
"Bpm": 90.0,
@ -1056,9 +1123,124 @@
],
"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",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 47.3,
"Bpm": 153.6,
@ -1105,6 +1287,7 @@
{
"Name": "MoyaZhittya",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 34.53,
"Bpm": 120.0,
@ -1295,6 +1478,7 @@
{
"Name": "MuzikaGromche",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 46.3,
"Bpm": 129.729721,
@ -1458,6 +1642,7 @@
{
"Name": "OnePartiyaUdar",
"IsExplicit": false,
"Season": null,
"Language": "Japanese",
"WindUpTimer": 41.27,
"Bpm": 130.06955,
@ -1492,9 +1677,59 @@
],
"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",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 39.68,
"Bpm": 130.612244,
@ -1530,9 +1765,302 @@
],
"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",
"IsExplicit": true,
"Season": null,
"Language": "English",
"WindUpTimer": 39.73,
"Bpm": 289.8113,
@ -1702,19 +2230,19 @@
],
[
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,
"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,
"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,
"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,
@ -1899,6 +2427,7 @@
{
"Name": "ReelGoon",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 45.15,
"Bpm": 117.997726,
@ -1958,6 +2487,7 @@
{
"Name": "RiseAndShine",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 59.87,
"Bpm": 137.8815,
@ -2014,6 +2544,7 @@
{
"Name": "Song2",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 38.63,
"Bpm": 50.0,
@ -2048,9 +2579,172 @@
],
"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",
"IsExplicit": false,
"Season": null,
"Language": "Russian",
"WindUpTimer": 38.28,
"Bpm": 137.965729,
@ -2195,6 +2889,7 @@
{
"Name": "Whistle",
"IsExplicit": false,
"Season": null,
"Language": "English",
"WindUpTimer": 41.27,
"Bpm": 104.016182,
@ -2345,6 +3040,7 @@
{
"Name": "Yalgaar",
"IsExplicit": false,
"Season": null,
"Language": "Hindi",
"WindUpTimer": 52.17,
"Bpm": 92.0157242,
@ -2382,6 +3078,7 @@
{
"Name": "ZmeiGorynich",
"IsExplicit": false,
"Season": null,
"Language": "Korean",
"WindUpTimer": 46.13,
"Bpm": 90.0014,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

View File

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

View File

@ -1,5 +1,11 @@
<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>
<template>

View File

@ -9,30 +9,32 @@
- exposes getPosition() to read current playback time relative to intro start
*/
import { type AudioTrack, useWrapTime, wrapTimeFn } from "@/lib/AudioTrack";
import type { Seconds } from "@/lib/units";
import type { ConfigurableWindow } from '@vueuse/core'
import type { MaybeRefOrGetter, Ref } from 'vue'
import type { AudioTrack } from '@/lib/AudioTrack'
import type { Seconds } from '@/lib/units'
import {
type ConfigurableWindow,
tryOnScopeDispose,
useRafFn,
useThrottleFn,
watchImmediate,
} from "@vueuse/core";
} from '@vueuse/core'
import {
type MaybeRefOrGetter,
type Ref,
shallowRef,
toValue,
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 {
/**
* 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 {
@ -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.
* 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,
): AudioTrackBuffersHandle {
const now = audioCtx.currentTime;
const now = audioCtx.currentTime
const introBuffer = audioTrack.loadedIntro!;
const loopBuffer = audioTrack.loadedLoop!;
const introBuffer = audioTrack.loadedIntro!
const loopBuffer = audioTrack.loadedLoop!
const introDuration = introBuffer.duration;
const loopDuration = loopBuffer.duration;
const introDuration = introBuffer.duration
const loopDuration = loopBuffer.duration
const wrapper = wrapTimeFn(audioTrack);
startPosition = wrapper(startPosition);
const wrapper = wrapTimeFn(audioTrack)
startPosition = wrapper(startPosition)
let currentIntro: AudioBufferSourceNode | null;
let currentLoop: AudioBufferSourceNode | null;
let introStartTime: Seconds;
let currentIntro: AudioBufferSourceNode | null
let currentLoop: AudioBufferSourceNode | null
let introStartTime: Seconds
// figure out where to start
if (startPosition < introDuration) {
// start intro with offset, schedule loop after remaining intro time
const introOffset = startPosition;
const timeUntilLoop = introDuration - introOffset;
const introOffset = startPosition
const timeUntilLoop = introDuration - introOffset
const introNode = audioCtx.createBufferSource();
introNode.buffer = introBuffer;
introNode.connect(destinationNode);
introNode.start(now, introOffset);
const introNode = audioCtx.createBufferSource()
introNode.buffer = introBuffer
introNode.connect(destinationNode)
introNode.start(now, introOffset)
const loopNode = audioCtx.createBufferSource();
loopNode.buffer = loopBuffer;
loopNode.loop = true;
loopNode.connect(destinationNode);
loopNode.start(now + timeUntilLoop, 0);
const loopNode = audioCtx.createBufferSource()
loopNode.buffer = loopBuffer
loopNode.loop = true
loopNode.connect(destinationNode)
loopNode.start(now + timeUntilLoop, 0)
currentIntro = introNode;
currentLoop = loopNode;
introStartTime = now - startPosition;
} else {
currentIntro = introNode
currentLoop = loopNode
introStartTime = now - startPosition
}
else {
// start directly in loop with proper offset into loop
const loopOffset = (startPosition - introDuration) % loopDuration;
const loopNode = audioCtx.createBufferSource();
loopNode.buffer = loopBuffer;
loopNode.loop = true;
loopNode.connect(destinationNode);
loopNode.start(now, loopOffset);
const loopOffset = (startPosition - introDuration) % loopDuration
const loopNode = audioCtx.createBufferSource()
loopNode.buffer = loopBuffer
loopNode.loop = true
loopNode.connect(destinationNode)
loopNode.start(now, loopOffset)
currentIntro = null;
currentLoop = loopNode;
currentIntro = null
currentLoop = loopNode
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
// introStartTime = now - introDuration - loopOffset;
introStartTime = now - startPosition;
introStartTime = now - startPosition
}
function stop(when?: Seconds) {
try {
currentIntro?.stop(when);
} catch (e) {
currentIntro?.stop(when)
}
catch {
/* ignore */
}
try {
currentLoop?.stop(when);
} catch (e) {
currentLoop?.stop(when)
}
catch {
/* ignore */
}
currentIntro = null;
currentLoop = null;
currentIntro = null
currentLoop = null
}
return { introStartTime, stop };
return { introStartTime, stop }
}
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
playerResult: Omit<T, "stop">;
playerResult: Omit<T, 'stop'>
}
/**
* 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.
@ -149,34 +154,34 @@ function playWithFadeInOut<T extends PlayerHandle>(
*/
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
): PlayWithFadeInOut<T> {
const GAIN_MIN = 0.0001;
const GAIN_MAX = 1.0;
const GAIN_MIN = 0.0001
const GAIN_MAX = 1.0
const fadeGain = audioCtx.createGain();
fadeGain.connect(destinationNode);
fadeGain.gain.value = GAIN_MIN;
const fadeGain = audioCtx.createGain()
fadeGain.connect(destinationNode)
fadeGain.gain.value = GAIN_MIN
const playerHandle = player(fadeGain);
const playerHandle = player(fadeGain)
// fade in
const now = audioCtx.currentTime;
const fadeEnd = now + fadeDuration;
fadeGain.gain.setValueAtTime(GAIN_MIN, now);
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd);
const now = audioCtx.currentTime
const fadeEnd = now + fadeDuration
fadeGain.gain.setValueAtTime(GAIN_MIN, now)
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd)
// TODO: setTimeout to actually stop after `when`?
function stop(_when?: Seconds) {
// fade out
const now = audioCtx.currentTime;
const fadeEnd = now + fadeDuration;
fadeGain.gain.cancelScheduledValues(now);
fadeGain.gain.setValueAtTime(GAIN_MAX, now);
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd);
const now = audioCtx.currentTime
const fadeEnd = now + fadeDuration
fadeGain.gain.cancelScheduledValues(now)
fadeGain.gain.setValueAtTime(GAIN_MAX, now)
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd)
playerHandle.stop(fadeEnd);
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 isPlaying: Readonly<Ref<boolean>>;
readonly isPlaying: Readonly<Ref<boolean>>
/**
* Readonly reference to the last remembered start-of-playback position.
*
* Will only update if stop(rememberPosition=true) or seek() is called.
*/
readonly startPosition: Readonly<Ref<Seconds>>;
readonly startPosition: Readonly<Ref<Seconds>>
/**
* Returns current playback position in seconds based on AudioContext time.
*
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
*/
getCurrentPosition(): Seconds;
getCurrentPosition: () => Seconds
}
export interface StopOptions {
@ -207,7 +212,7 @@ export interface StopOptions {
*
* Defaults to false.
*/
rememberPosition?: boolean;
rememberPosition?: boolean
}
export interface SeekOptions {
@ -216,7 +221,7 @@ export interface SeekOptions {
*
* Defaults to false.
*/
scrub?: boolean;
scrub?: boolean
// TODO: optionally keep playing after seeking?
}
@ -227,24 +232,24 @@ export interface PlayerControls {
/**
* Start playing audio buffers from the last remembered position.
*/
play: () => void;
play: () => void
/**
* Stop playing audio buffers.
*
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
*/
stop: (options?: StopOptions) => void;
stop: (options?: StopOptions) => void
/**
* Seek to given position in seconds.
*
* - Stop the playback.
* - If scrub is requested, plays a short sample at that position.
*/
seek: (position: Seconds, options?: SeekOptions) => void;
seek: (position: Seconds, options?: SeekOptions) => void
/**
* Properties relates to the state of playback.
*/
readonly playback: PlaybackState;
readonly playback: PlaybackState
}
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
@ -255,58 +260,44 @@ function reusableAudioBuffersTrackPlayer(
destinationNode: AudioNode,
audioTrack: AudioTrack,
): ReusableAudioBuffersTrackPlayer {
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null;
const isPlaying = shallowRef(false);
const wrapper = wrapTimeFn(audioTrack);
const startPosition = useWrapTime(audioTrack, 0);
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null
const isPlaying = shallowRef(false)
const wrapper = wrapTimeFn(audioTrack)
const startPosition = useWrapTime(audioTrack, 0)
function play() {
if (currentHandle) {
return;
return
}
currentHandle = playWithFadeInOut(
audioCtx,
destinationNode,
(destinationNode) =>
destinationNode =>
playAudioTrackBuffers(
audioCtx,
destinationNode,
audioTrack,
startPosition.value,
),
);
isPlaying.value = true;
)
isPlaying.value = true
}
function stop(options?: { rememberPosition?: boolean }) {
const {
rememberPosition = false,
} = options ?? {};
} = options ?? {}
if (currentHandle) {
isPlaying.value = false;
isPlaying.value = false
if (rememberPosition) {
startPosition.value = getCurrentPosition();
startPosition.value = getCurrentPosition()
}
// stop and discard current handle
currentHandle.stop();
currentHandle = null;
}
}
function seek(seekPosition: Seconds, options?: SeekOptions) {
const {
scrub = false,
} = options ?? {};
stop({ rememberPosition: false });
startPosition.value = seekPosition;
if (scrub) {
doThrottledScrub();
currentHandle.stop()
currentHandle = null
}
}
@ -317,7 +308,7 @@ function reusableAudioBuffersTrackPlayer(
const scrubHandle = playWithFadeInOut(
audioCtx,
destinationNode,
(destinationNode) =>
destinationNode =>
playAudioTrackBuffers(
audioCtx,
destinationNode,
@ -325,21 +316,35 @@ function reusableAudioBuffersTrackPlayer(
startPosition.value,
),
0.01, // short fade of 10 ms
);
)
setTimeout(() => {
scrubHandle.stop(0.01);
}, 80); // stop after N ms
}, 80);
scrubHandle.stop(0.01)
}, 80) // stop after N ms
}, 80)
function seek(seekPosition: Seconds, options?: SeekOptions) {
const {
scrub = false,
} = options ?? {}
stop({ rememberPosition: false })
startPosition.value = seekPosition
if (scrub) {
doThrottledScrub()
}
}
function getCurrentPosition(): Seconds {
if (!currentHandle) {
return startPosition.value;
return startPosition.value
}
const elapsed = audioCtx.currentTime -
currentHandle.playerResult.introStartTime;
const elapsed = audioCtx.currentTime
- currentHandle.playerResult.introStartTime
return wrapper(elapsed);
return wrapper(elapsed)
}
return {
@ -351,183 +356,199 @@ function reusableAudioBuffersTrackPlayer(
startPosition,
getCurrentPosition,
},
};
}
}
interface LivePlaybackPositionOptions extends ConfigurableWindow {
}
interface LivePlaybackPositionReturn {
stop: () => void;
position: Readonly<Ref<Seconds>>;
stop: () => void
position: Readonly<Ref<Seconds>>
}
export function useLivePlaybackPosition(
playback: MaybeRefOrGetter<PlaybackState | null>,
options?: LivePlaybackPositionOptions,
): LivePlaybackPositionReturn {
const cleanups: Function[] = [];
const cleanups: (() => void)[] = []
const cleanup = () => {
cleanups.forEach((fn) => fn());
cleanups.length = 0;
};
cleanups.forEach(fn => fn())
cleanups.length = 0
}
const getPosition = () => {
return toValue(playback)?.getCurrentPosition() ?? 0;
};
return toValue(playback)?.getCurrentPosition() ?? 0
}
const position = shallowRef<Seconds>(getPosition());
const position = shallowRef<Seconds>(getPosition())
const updatePosition = () => {
position.value = getPosition();
};
position.value = getPosition()
}
const raf = useRafFn(() => {
updatePosition();
updatePosition()
}, {
...options,
immediate: false,
once: false,
});
})
const stopWatch = watchImmediate(() => [
toValue(playback),
], ([playback]) => {
cleanup();
cleanup()
updatePosition();
updatePosition()
if (!playback) return;
if (!playback) {
return
}
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
if (isPlaying) {
raf.resume();
} else {
raf.pause();
updatePosition();
raf.resume()
}
}));
else {
raf.pause()
updatePosition()
}
}))
cleanups.push(watch(playback.startPosition, () => {
raf.pause();
updatePosition();
raf.pause()
updatePosition()
if (playback.isPlaying.value) {
raf.resume();
raf.resume()
}
}));
}))
cleanups.push(() => raf.pause());
});
cleanups.push(() => raf.pause())
})
const stop = () => {
stopWatch();
cleanup();
};
stopWatch()
cleanup()
}
tryOnScopeDispose(cleanup);
tryOnScopeDispose(cleanup)
return { stop, position };
return { stop, position }
}
export function togglePlayStop(
player: PlayerControls | null,
options?: StopOptions,
) {
if (!player) return;
if (!player) {
return
}
if (player.playback.isPlaying.value) {
player.stop(options);
} else {
player.play();
player.stop(options)
}
else {
player.play()
}
}
class AudioEngine {
audioCtx: AudioContext | null = null;
masterGain: GainNode | null = null; // controlled by UI volume slider
audioCtx: AudioContext | null = null
masterGain: GainNode | null = null // controlled by UI volume slider
// fadeGain: GainNode | null = null; // tiny fade to avoid clicks
// cache of decoded buffers by URL
bufferCache = new Map<string, AudioBuffer>();
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;
// settings
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
fadeDuration = 0.025 // 25 ms for fade-in/fade-out
init() {
if (this.audioCtx) return;
this.audioCtx =
new (window.AudioContext || (window as any).webkitAudioContext)();
if (this.audioCtx) {
return
}
this.audioCtx
= new (window.AudioContext || (window as any).webkitAudioContext)()
this.masterGain = this.audioCtx.createGain();
this.masterGain = this.audioCtx.createGain()
// routing: sources -> fadeGain -> masterGain -> destination
this.masterGain.connect(this.audioCtx.destination);
this.masterGain.connect(this.audioCtx.destination)
// default full volume
this.masterGain.gain.value = 1;
this.masterGain.gain.value = 1
}
shutdown() {
this.stopPlayer();
this.audioCtx?.close();
this.audioCtx = null;
this.masterGain = null;
this.stopPlayer()
this.audioCtx?.close()
this.audioCtx = null
this.masterGain = null
}
async fetchAudioBuffer(
url: string,
signal?: AbortSignal,
): Promise<AudioBuffer> {
this.init();
if (this.bufferCache.has(url)) return this.bufferCache.get(url)!;
const res = await fetch(url, { signal });
if (!res.ok) {
throw new Error(`Network error ${res.status} when fetching ${url}`);
this.init()
if (this.bufferCache.has(url)) {
return this.bufferCache.get(url)!
}
const arrayBuffer = await res.arrayBuffer();
const audioBuffer = await this.audioCtx!.decodeAudioData(arrayBuffer);
this.bufferCache.set(url, audioBuffer);
return audioBuffer;
const res = await fetch(url, { signal })
if (!res.ok) {
throw new Error(`Network error ${res.status} when fetching ${url}`)
}
const arrayBuffer = await res.arrayBuffer()
const audioBuffer = await this.audioCtx!.decodeAudioData(arrayBuffer)
this.bufferCache.set(url, audioBuffer)
return audioBuffer
}
// set UI volume 0..VOLUME_MAX
setVolume(value: number) {
this.init();
if (!this.masterGain || !this.audioCtx) return;
const now = this.audioCtx.currentTime;
this.init()
if (!this.masterGain || !this.audioCtx) {
return
}
const now = this.audioCtx.currentTime
// small linear ramp to avoid jumps
this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05);
this.masterGain.gain.cancelScheduledValues(now)
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now)
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05)
}
initPlayer(
audioTrack: AudioTrack,
): PlayerControls | null {
this.init();
if (!this.audioCtx || !this.masterGain) return null;
this.init()
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(
this.audioCtx,
this.masterGain,
audioTrack,
);
this._player.value = player;
return player;
)
this._player.value = player
return player
}
private stopPlayer() {
if (this._player.value) {
this._player.value.stop();
this._player.value = null;
this._player.value.stop()
this._player.value = null
}
}
}
const audioEngine = new AudioEngine();
export default audioEngine;
const audioEngine = new AudioEngine()
export default audioEngine

View File

@ -1,49 +1,50 @@
import type { Px } from "@/lib/units";
import { useWeakCache } from "@/lib/useWeakCache";
import { type Fn, tryOnScopeDispose, watchImmediate } from "@vueuse/core";
import type { MaybeRefOrGetter, Ref } from "vue";
import { computed, shallowRef, toValue, triggerRef } from "vue";
import type { Fn } from '@vueuse/core'
import type { MaybeRefOrGetter, Ref } from 'vue'
import type { Px } from '@/lib/units'
import { tryOnScopeDispose, watchImmediate } from '@vueuse/core'
import { computed, shallowRef, toValue, triggerRef } from 'vue'
import { useWeakCache } from '@/lib/useWeakCache'
// Result of async computation
interface UseWaveform {
readonly isDone: Readonly<Ref<boolean>>;
readonly peaks: Readonly<Ref<Float32Array>>;
stop: () => void;
readonly isDone: Readonly<Ref<boolean>>
readonly peaks: Readonly<Ref<Float32Array>>
stop: () => void
}
interface WaveformComputation {
readonly isDone: Readonly<Ref<boolean>>;
readonly peaks: Readonly<Ref<Float32Array>>;
readonly isDone: Readonly<Ref<boolean>>
readonly peaks: Readonly<Ref<Float32Array>>
/** Start or continue asynchronous computation. */
run: () => void;
run: () => void
/** Stops any ongoing asynchronous computation. */
stop: () => void;
stop: () => void
}
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
() => new Map(),
);
)
const WAVEFORM_MIN_WIDTH = 10;
const WAVEFORM_MIN_WIDTH = 10
const emptyComputation: WaveformComputation = {
isDone: shallowRef(false),
peaks: shallowRef(new Float32Array(0)),
run() {},
stop() {},
};
}
export function useWaveform(
buffer: MaybeRefOrGetter<AudioBuffer>,
width: MaybeRefOrGetter<Px>,
): UseWaveform {
const cleanups: Fn[] = [];
const cleanups: Fn[] = []
const cleanup = () => {
cleanups.forEach((fn) => fn());
cleanups.length = 0;
};
cleanups.forEach(fn => fn())
cleanups.length = 0
}
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation);
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation)
const stopWatch = watchImmediate(
() =>
@ -52,140 +53,138 @@ export function useWaveform(
toValue(width),
] as const,
([b, w]) => {
cleanup();
cleanup()
const map = waveformsCache.getOrNew(b);
const map = waveformsCache.getOrNew(b)
if (w < WAVEFORM_MIN_WIDTH) {
compRef.value = emptyComputation;
return;
compRef.value = emptyComputation
return
}
let comp = map.get(w);
let comp = map.get(w)
if (!comp) {
comp = useWaveformComputation(b, w);
map.set(w, comp);
comp = useWaveformComputation(b, w)
map.set(w, comp)
}
compRef.value = comp;
comp.run();
compRef.value = comp
comp.run()
cleanups.push(() => {
compRef.value = emptyComputation;
comp.stop();
});
compRef.value = emptyComputation
comp.stop()
})
},
);
)
const stop = () => {
stopWatch();
cleanup();
};
stopWatch()
cleanup()
}
tryOnScopeDispose(stop);
tryOnScopeDispose(stop)
return {
isDone: computed(() => compRef.value.isDone.value),
peaks: computed(() => compRef.value.peaks.value),
stop,
};
}
}
const useWaveformComputation = (
buffer: AudioBuffer,
width: Px,
): WaveformComputation => {
function useWaveformComputation(buffer: AudioBuffer, width: Px): WaveformComputation {
// How many times run() has been called without stop().
// This whole computation should not stop until there is at least one user out there.
let users = 0;
let users = 0
// 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
const waveform = new Float32Array(width);
const waveform = new Float32Array(width)
const isDone = shallowRef(false);
const peaks = shallowRef(waveform);
const isDone = shallowRef(false)
const peaks = shallowRef(waveform)
const nChannels = buffer.numberOfChannels;
const nChannels = buffer.numberOfChannels
const samplesPerPx = buffer.length / width;
const blocksPerChannel: Float32Array<ArrayBuffer>[] = [];
const samplesPerPx = buffer.length / width
const blocksPerChannel: Float32Array<ArrayBuffer>[] = []
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() {
const blockStart = Math.floor(progress * samplesPerPx);
const blockEnd = Math.floor((progress + 1) * samplesPerPx);
const blockSize = blockEnd - blockStart;
const blockStart = Math.floor(progress * samplesPerPx)
const blockEnd = Math.floor((progress + 1) * samplesPerPx)
const blockSize = blockEnd - blockStart
for (let channel = 0; channel < nChannels; channel++) {
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart);
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart)
}
waveform[progress] = compressBlock(blocksPerChannel, blockSize);
progress += 1;
waveform[progress] = compressBlock(blocksPerChannel, blockSize)
progress += 1
}
function stepBatchOfBlocks() {
// run blocks for up to ~10ms to keep UI responsive
const start = performance.now();
const progressStart = progress;
const start = performance.now()
const progressStart = progress
while (!areWeDoneYet()) {
stepBlock();
stepBlock()
if (performance.now() - start >= 10 || progress - progressStart > 100) {
break;
break
}
}
triggerRef(peaks);
triggerRef(peaks)
// triggerRef may as well not trigger refs
// https://github.com/vuejs/core/issues/9579
// Combined with a throttled drawing function,
// this is a slightly better-than-worse workaround.
peaks.value = new Float32Array(0);
peaks.value = waveform;
peaks.value = new Float32Array(0)
peaks.value = waveform
if (areWeDoneYet()) {
isDone.value = true;
timeoutID = NaN;
} else {
timeoutID = setTimeout(stepBatchOfBlocks, 1);
isDone.value = true
timeoutID = undefined
}
else {
timeoutID = setTimeout(stepBatchOfBlocks, 1)
}
}
let timeoutID: number = NaN;
return {
isDone,
peaks,
run() {
users += 1;
users += 1
if (Number.isNaN(timeoutID) && users === 1) {
timeoutID = setTimeout(stepBatchOfBlocks, 0);
if (timeoutID === undefined && users === 1) {
timeoutID = setTimeout(stepBatchOfBlocks, 0)
}
},
stop() {
users -= 1;
users -= 1
if (!Number.isNaN(timeoutID) && users === 0) {
window.clearTimeout(timeoutID);
timeoutID = NaN;
if (!timeoutID === undefined && users === 0) {
window.clearTimeout(timeoutID)
timeoutID = undefined
}
},
};
}
};
function compressBlock(channels: Float32Array[], blockSize: number): number {
let peak = 0.0;
let peak = 0.0
for (let i = 0; i < blockSize; i++) {
for (let channel = 0; channel < channels.length; channel++) {
peak = Math.max(peak, Math.abs(channels[channel]![i]!));
peak = Math.max(peak, Math.abs(channels[channel]![i]!))
}
}
return peak;
return peak
}

View File

@ -3,18 +3,22 @@ const {
title,
description,
} = defineProps<{
title: string,
description: string | null,
}>();
title: string
description: string | null
}>()
</script>
<template>
<div class="tw:h-full tw:flex tw:flex-col">
<div class="tw:w-full tw:max-w-2xl tw:self-center">
<h1 class="tw:text-4xl tw:p-8">{{ title }}</h1>
<h1 class="tw:text-4xl tw:p-8">
{{ title }}
</h1>
<p class="tw:p-4">
{{ description }}
</p>
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,25 +1,23 @@
<script setup lang="ts">
import { useTrackStore } from '@/store/TrackStore';
import { storeToRefs } from 'pinia';
// import OpenInNew from '@material-design-icons/svg/outlined/open_in_new.svg?url&inline';
// import OpenInNew2 from '@material-design-icons/svg/outlined/open_in_new.svg';
import { storeToRefs } from 'pinia'
import { useTrackStore } from '@/store/TrackStore'
const trackStore = useTrackStore();
const { version } = storeToRefs(trackStore);
const product = "MuzikaGromche";
const productLink = "https://thunderstore.io/c/lethal-company/p/Ratijas/MuzikaGromche/";
const year = 2025;
const author = "Ratijas";
const authorLink = "https://ratijas.me";
const trackStore = useTrackStore()
const { version } = storeToRefs(trackStore)
const product = 'MuzikaGromche'
const productLink = 'https://thunderstore.io/c/lethal-company/p/Ratijas/MuzikaGromche/'
const year = '20252026'
const author = 'Ratijas'
const authorLink = 'https://ratijas.me'
</script>
<template>
<footer>
<!-- TODO: A bug/omission somewhere in Vite/Rolldown/SVGO prevents Vite/SVGO plugin config from injecting { "fill": "currentColor" } into ?inline imported SVG. -->
<div
class="tw:py-2 tw:px-4 tw:gap-2 tw:flex tw:flex-row tw:max-sm:flex-col tw:flex-wrap tw:items-center tw:justify-center toolbar-background"
style="border-top: var(--view-separator-border);">
style="border-top: var(--view-separator-border);"
>
<span>
<span>
<a :href="productLink" target="_blank" rel="nofollow">{{ product }}</a>
@ -41,6 +39,7 @@ const authorLink = "https://ratijas.me";
</div>
</footer>
</template>
<style scoped>
.separator {
align-self: stretch;

View File

@ -1,33 +1,34 @@
<script setup lang="ts">
import { TransitionPresets, useTransition, watchDebounced } from "@vueuse/core";
import { computed, shallowRef, useId } from "vue";
import ScreenTransition from "./ScreenTransition.vue";
import { TransitionPresets, useTransition, watchDebounced } from '@vueuse/core'
import { computed, shallowRef, useId } from 'vue'
import ScreenTransition from './ScreenTransition.vue'
const {
visible,
message = "Loading…",
message = 'Loading…',
progress = undefined,
} = defineProps<{
visible: boolean,
message?: string,
visible: boolean
message?: string
// loading progress, range 0..1
progress?: number | undefined,
}>();
progress?: number | undefined
}>()
// CSS transition on width does not work in Firefox
const zeroProgress = computed(() => progress ?? 0);
const zeroProgress = computed(() => progress ?? 0)
const easedProgress = useTransition(zeroProgress, {
duration: 400,
easing: TransitionPresets.easeInOutQuad,
});
})
const progressId = useId();
const progressId = useId()
// Let the progress animation finish before cutting it off of updates
const actuallyVisible = shallowRef(visible);
const actuallyVisible = shallowRef(visible)
watchDebounced(() => visible, () => {
actuallyVisible.value = visible;
actuallyVisible.value = visible
}, { debounce: 600 })
</script>
<template>
<ScreenTransition :visible="actuallyVisible">
<div class="tw:h-full tw:flex tw:flex-col tw:gap-8 tw:items-center tw:justify-center tw:text-2xl">

View File

@ -1,17 +1,19 @@
<script setup lang="ts">
defineProps<{
visible: boolean,
}>();
visible: boolean
}>()
</script>
<template>
<Transition>
<div v-if="visible" class="tw:h-full tw:overflow-hidden tw:isolate" style="background-color: var(--main-background-color);">
<div class="tw:h-full">
<slot></slot>
<slot />
</div>
</div>
</Transition>
</template>
<style scoped>
@property --screen-transition-duration {
syntax: "<time>";

View File

@ -1,30 +1,34 @@
<script setup lang="ts">
import Search from '@material-design-icons/svg/outlined/search.svg';
import Clear from '@material-design-icons/svg/outlined/clear.svg';
import { computed } from 'vue';
import Clear from '@material-design-icons/svg/outlined/clear.svg'
import Search from '@material-design-icons/svg/outlined/search.svg'
import { computed } from 'vue'
const {
placeholder = "Search…",
placeholder = 'Search…',
} = defineProps<{
placeholder?: string,
}>();
const model = defineModel({ type: String, required: true });
placeholder?: string
}>()
const model = defineModel({ type: String, required: true })
const clearDisabled = computed(() => model.value === "");
const clearDisabled = computed(() => model.value === '')
function clear() {
model.value = "";
model.value = ''
}
</script>
<template>
<div class="tw:flex tw:gap-1 tw:p-0.5 tw:px-1 tw:place-items-center tw:justify-center tw:text-xl input-text">
<Search class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" style="color: #929292;" />
<input type="text" role="searchbox" v-model="model" :placeholder class="tw:flex-1 tw:min-w-0" />
<button type="button" role="button" name="clear" aria-roledescription="Clear search field" tabindex="-1" title="Clear"
@click="clear" :disabled="clearDisabled" class="button">
<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"
:disabled="clearDisabled" class="button" @click="clear"
>
<Clear class="tw:flex-none tw:h-full tw:aspect-square tw:fill-current" />
</button>
</div>
</template>
<style scoped>
.button {
color: #929292;

View File

@ -1,9 +1,9 @@
<script lang="ts" setup>
import { useTimelineStore } from '@/store/TimelineStore';
import { useTrackStore } from '@/store/TrackStore';
import { useTimelineStore } from '@/store/TimelineStore'
import { useTrackStore } from '@/store/TrackStore'
const trackStore = useTrackStore();
const timeline = useTimelineStore();
const trackStore = useTrackStore()
const timeline = useTimelineStore()
</script>
<template>

View File

@ -1,20 +1,22 @@
<script setup lang="ts">
import Timestamp from '@/components/timeline/Timestamp.vue';
import { LANGUAGES, type AudioTrack } from '@/lib/AudioTrack';
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
import { computed } from 'vue';
import type { AudioTrack } from '@/lib/AudioTrack'
import Explicit from '@material-design-icons/svg/filled/explicit.svg'
import { computed } from 'vue'
import Timestamp from '@/components/timeline/Timestamp.vue'
import { LANGUAGES } from '@/lib/AudioTrack'
const {
track,
edit,
} = defineProps<{
track: AudioTrack,
edit: boolean,
}>();
track: AudioTrack
edit: boolean
}>()
const beatSeconds = computed(() => track.loadedLoop!.duration / track.Beats);
const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
const beatSeconds = computed(() => track.loadedLoop!.duration / track.Beats)
const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value)
</script>
<template>
<div class="info-wrapper">
<table class="info-table">
@ -29,7 +31,9 @@ const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
<td>Language:</td>
<td>
<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>
<span v-else>
{{ track.Language }}
@ -38,14 +42,15 @@ const windUpBeats = computed(() => track.WindUpTimer / beatSeconds.value);
</tr>
<tr>
<td>Is explicit:</td>
<td><div style="display: flex; gap: 8px;">
<input type="checkbox" id="isExplicit" :checked="track.IsExplicit" :disabled="!edit" />
<label for="isExplicit" style="flex: 1;">
<div title="Warning: Explicit Content" class="tw:flex-none">
<Explicit class="tw:w-6 tw:h-6 tw:fill-current" />
</div>
</label>
</div>
<td>
<div style="display: flex; gap: 8px;">
<input id="isExplicit" type="checkbox" :checked="track.IsExplicit" :disabled="!edit">
<label for="isExplicit" style="flex: 1;">
<div title="Warning: Explicit Content" class="tw:flex-none">
<Explicit class="tw:w-6 tw:h-6 tw:fill-current" />
</div>
</label>
</div>
</td>
</tr>
<tr>

View File

@ -1,24 +1,21 @@
<script setup lang="ts">
import ScrollablePanel from "@/components/library/panel/ScrollablePanel.vue";
import Construction from "@material-design-icons/svg/round/construction.svg";
import { computed, shallowRef } from "vue";
import AudioTrack from "./views/AudioTrack.vue";
import { useTimelineStore } from "@/store/TimelineStore";
import { storeToRefs } from "pinia";
import Construction from '@material-design-icons/svg/round/construction.svg'
import { storeToRefs } from 'pinia'
import { shallowRef } from 'vue'
import ScrollablePanel from '@/components/library/panel/ScrollablePanel.vue'
import { useTimelineStore } from '@/store/TimelineStore'
import AudioTrack from './views/AudioTrack.vue'
// TODO: use selection (inspector?) manager
const selection = shallowRef<object | null>({});
const selection = shallowRef<object | null>({})
const timeline = useTimelineStore();
const { audioTrack, tracksMap } = storeToRefs(timeline);
const introClip = computed(() => tracksMap.value.intro.clips[0]);
const timeline = useTimelineStore()
const { audioTrack } = storeToRefs(timeline)
</script>
<template>
<!-- inspector panel -->
<ScrollablePanel class="tw:flex-none tw:min-w-80 tw:max-w-80 tw:border-s">
<template #toolbar>
<h3 class="tw:flex tw:flex-row tw:items-center tw:gap-2 tw:px-4 tw:py-1 tw:select-none">
<Construction class="tw:fill-current tw:h-5 tw:w-5 toolbar-icon-shadow" />
@ -29,22 +26,20 @@ const introClip = computed(() => tracksMap.value.intro.clips[0]);
<template #default>
<!-- inspector content -->
<div class="tw:px-4 tw:h-full tw:flex tw:flex-col" @click="selection = selection ? {} : {}">
<!-- nothing to inspect -->
<div v-if="!selection"
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none">
<div
v-if="!selection"
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none"
>
Nothing to inspect
</div>
<!-- inspect selection -->
<div v-else class="tw:flex-1 tw:flex tw:flex-col tw:gap-4 tw:py-2 tw:text-xs">
<AudioTrack v-if="audioTrack" :audioTrack />
<!-- <Clip v-if="introClip" :clip="introClip" /> -->
<AudioTrack v-if="audioTrack" :audio-track />
</div>
</div>
</template>
</ScrollablePanel>
</template>

View File

@ -1,16 +1,15 @@
<script setup lang="ts">
import type { Control } from "@/components/inspector/controls";
import { computed } from "vue";
import { getComponentFor } from "./impl";
import type { Control } from '@/components/inspector/controls'
import { computed } from 'vue'
import { getComponentFor } from './impl'
const {
control,
} = defineProps<{
control: Control;
}>();
const view = computed(() => getComponentFor(control));
control: Control
}>()
const view = computed(() => getComponentFor(control))
</script>
<template>

View File

@ -1,18 +1,19 @@
<script setup lang="ts">
import type { Controls } from "../controls";
import Control from "./Control.vue";
import type { Controls } from '../controls'
import Control from './Control.vue'
const {
controls,
} = defineProps<{
controls: Controls;
}>();
controls: Controls
}>()
</script>
<template>
<div class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
style="grid-template-columns: 80px minmax(0, 1fr);">
<div
class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
style="grid-template-columns: 80px minmax(0, 1fr);"
>
<Control v-for="control in controls" :key="control.key" :control />
</div>
</template>

View File

@ -1,27 +1,31 @@
<script setup lang="ts">
import type { BaseNamedControl } from "@/components/inspector/controls";
import type { BaseNamedControl } from '@/components/inspector/controls'
const {
control,
id,
} = defineProps<{
control: BaseNamedControl;
control: BaseNamedControl
/**
* Input ID for an associated label.
*/
id?: string;
}>();
id?: string
}>()
// TODO: reset function
function reset(event: MouseEvent) {
event.preventDefault();
event.preventDefault()
}
</script>
<template>
<!-- label -->
<label :for="id" class="tw:text-right control-label" :class="{ 'control-label__disabled': control.disabled }"
@dblclick="reset">
<label
:for="id"
class="tw:text-right control-label"
:class="{ 'control-label__disabled': control.disabled }"
@dblclick="reset"
>
{{ control.name }}
</label>

View File

@ -1,19 +1,28 @@
<script setup lang="ts">
import type { ButtonControl } from "@/components/inspector/controls";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import type { ButtonControl } from '@/components/inspector/controls'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: ButtonControl;
}>();
control: ButtonControl
}>()
</script>
<template>
<BaseNamedControlView :control>
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-start">
<button type="button" @click="control.action" :disabled="control.disabled" class="control-button">
<component v-if="control.icon" :is="control.icon" class="control-button__icon" />
<button
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>
</button>
</div>

View File

@ -1,22 +1,29 @@
<script setup lang="ts">
import type { CheckboxControl } from "@/components/inspector/controls";
import { useId } from "vue";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import type { CheckboxControl } from '@/components/inspector/controls'
import { useId } from 'vue'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: CheckboxControl;
}>();
control: CheckboxControl
}>()
const id = useId();
const id = useId()
</script>
<template>
<BaseNamedControlView :control :id>
<label class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
:class="{ 'control-label__disabled': control.disabled }">
<input type="checkbox" v-model="control.ref" :id :disabled="control.disabled" />
<BaseNamedControlView :id :control>
<label
class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
: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" />
<span v-if="control.label">
{{ control.label }}

View File

@ -1,25 +1,28 @@
<script setup lang="ts">
import type { DropDownControl } from "@/components/inspector/controls";
import { useId } from "vue";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import type { DropDownControl } from '@/components/inspector/controls'
import { useId } from 'vue'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: DropDownControl;
}>();
control: DropDownControl
}>()
const id = useId();
const id = useId()
</script>
<template>
<BaseNamedControlView :control :id>
<BaseNamedControlView :id :control>
<div>
<select :id 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" :value="option">
<select
:id
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 }}
<!-- and very long text what gonna happen -->
</option>
</select>
</div>

View File

@ -1,15 +1,14 @@
<script setup lang="ts">
import type { HrControl } from "@/components/inspector/controls";
import type { HrControl } from '@/components/inspector/controls'
defineProps<{
control: HrControl;
}>();
control: HrControl
}>()
</script>
<template>
<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>
</template>

View File

@ -1,14 +1,15 @@
<script setup lang="ts">
import type { BaseControl } from "@/components/inspector/controls";
import type { BaseControl } from '@/components/inspector/controls'
defineProps<{
control: BaseControl;
}>();
control: BaseControl
}>()
</script>
<template>
<div class="tw:col-span-full" >Not Implemented: {{ control.kind }}</div>
<div class="tw:col-span-full">
Not Implemented: {{ control.kind }}
</div>
</template>
<style scoped></style>

View File

@ -1,22 +1,31 @@
<script setup lang="ts">
import type { NumberControl } from "@/components/inspector/controls";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import { useId } from "vue";
import type { NumberControl } from '@/components/inspector/controls'
import { useId } from 'vue'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: NumberControl;
}>();
control: NumberControl
}>()
const id = useId();
const id = useId()
</script>
<template>
<BaseNamedControlView :control :id>
<BaseNamedControlView :id :control>
<div>
<input :id type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-20" />
<input
: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>
</BaseNamedControlView>
</template>

View File

@ -1,27 +1,40 @@
<script setup lang="ts">
import type { RangeControl } from "@/components/inspector/controls";
import Slider from "@/components/library/Slider.vue";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import { useId } from "vue";
import type { RangeControl } from '@/components/inspector/controls'
import { useId } from 'vue'
import Slider from '@/components/library/Slider.vue'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: RangeControl;
}>();
control: RangeControl
}>()
const id = useId();
const id = useId()
</script>
<template>
<BaseNamedControlView :control :id>
<BaseNamedControlView :id :control>
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
<Slider :id v-model.number="control.ref.value"
@update:model-value="(value) => control.ref.value = value ?? control.default" :min="control.min"
:max="control.max" :step="0.01" :defaultValue="0" :disabled="control.disabled || control.readonly"
class="tw:flex-1 tw:self-end" />
<input type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-14" />
<Slider
:id v-model.number="control.ref.value"
:min="control.min" :max="control.max"
:step="0.01"
:default-value="0"
: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>
</BaseNamedControlView>
</template>

View File

@ -1,21 +1,26 @@
<script setup lang="ts">
import type { TextControl } from "@/components/inspector/controls";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import type { TextControl } from '@/components/inspector/controls'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: TextControl;
}>();
control: TextControl
}>()
</script>
<template>
<BaseNamedControlView :control>
<div>
<textarea rows="4" v-model="control.ref.value" :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" />
<textarea
v-model="control.ref.value"
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>
</BaseNamedControlView>
</template>

View File

@ -1,20 +1,24 @@
<script setup lang="ts">
import type { TextControl } from "@/components/inspector/controls";
import BaseNamedControlView from "./BaseNamedControlView.vue";
import type { TextControl } from '@/components/inspector/controls'
import BaseNamedControlView from './BaseNamedControlView.vue'
const {
control,
} = defineProps<{
control: TextControl;
}>();
control: TextControl
}>()
</script>
<template>
<BaseNamedControlView :control>
<div class="input-text">
<input type="text" :value="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
class="tw:w-full tw:max-w-full" />
<input
type="text"
:value="control.ref.value"
:disabled="control.disabled"
:readonly="control.readonly"
class="tw:w-full tw:max-w-full"
>
</div>
</BaseNamedControlView>
</template>

View File

@ -1,19 +1,19 @@
import type { Component } from "vue";
import type { Control } from "..";
import ButtonControlView from "./ButtonControlView.vue";
import CheckboxControlView from "./CheckboxControlView.vue";
import DropDownControlView from "./DropDownControlView.vue";
import HrControlView from "./HrControlView.vue";
import NotImplementedControlView from "./NotImplementedControlView.vue";
import NumberControlView from "./NumberControlView.vue";
import RangeControlView from "./RangeControlView.vue";
import TextAreaControlView from "./TextAreaControlView.vue";
import TextControlView from "./TextControlView.vue";
import type { Component } from 'vue'
import type { Control } from '..'
import ButtonControlView from './ButtonControlView.vue'
import CheckboxControlView from './CheckboxControlView.vue'
import DropDownControlView from './DropDownControlView.vue'
import HrControlView from './HrControlView.vue'
import NotImplementedControlView from './NotImplementedControlView.vue'
import NumberControlView from './NumberControlView.vue'
import RangeControlView from './RangeControlView.vue'
import TextAreaControlView from './TextAreaControlView.vue'
import TextControlView from './TextControlView.vue'
/**
* Mapping from `control.kind` to the component that renders it.
*/
const viewMap: Record<Control["kind"], Component> = {
const viewMap: Record<Control['kind'], Component> = {
hr: HrControlView,
text: TextControlView,
textarea: TextAreaControlView,
@ -22,7 +22,7 @@ const viewMap: Record<Control["kind"], Component> = {
checkbox: CheckboxControlView,
dropdown: DropDownControlView,
button: ButtonControlView,
};
}
/**
* 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.
*/
export function getComponentFor<T extends Control>(control: T): Component {
return viewMap[control.kind] ?? NotImplementedControlView;
return viewMap[control.kind] ?? NotImplementedControlView
}

View File

@ -1,99 +1,99 @@
import type { Component, Ref } from "vue";
import type { Component, Ref } from 'vue'
export interface BaseControl {
/**
* Discriminator for different types of controls.
*/
kind: string;
kind: string
/**
* Unique key of the control.
*/
key: string;
key: string
}
export interface HrControl extends BaseControl {
kind: "hr";
kind: 'hr'
}
export interface BaseNamedControl extends BaseControl {
/**
* 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.
*/
icon?: string | Component;
icon?: string | Component
/**
* 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.
*/
readonly?: boolean;
readonly?: boolean
}
export interface BaseTextControl extends BaseNamedControl {
ref: Ref<string>;
ref: Ref<string>
/** Whether to use monospace font. Defaults to false. */
monospace?: boolean;
monospace?: boolean
}
export interface TextControl extends BaseTextControl {
kind: "text";
kind: 'text'
}
export interface TextAreaControl extends BaseTextControl {
kind: "textarea";
kind: 'textarea'
}
export interface BaseNumberControl extends BaseNamedControl {
min: number;
max: number;
default: number;
ref: Ref<number>;
min: number
max: number
default: number
ref: Ref<number>
}
/** A range slider accompanied by an input field. */
export interface RangeControl extends BaseNumberControl {
kind: "range";
kind: 'range'
}
/** A text input field for a number. */
export interface NumberControl extends BaseNumberControl {
kind: "number";
kind: 'number'
}
export interface CheckboxControl extends BaseNamedControl {
kind: "checkbox";
kind: 'checkbox'
/** Optional additional label for the checkbox input */
label?: string;
ref: Ref<boolean>;
label?: string
ref: Ref<boolean>
}
export interface DropDownControl extends BaseNamedControl {
kind: "dropdown";
options: readonly string[];
ref: Ref<string>;
kind: 'dropdown'
options: readonly string[]
ref: Ref<string>
}
export interface ButtonControl extends BaseNamedControl {
kind: "button";
kind: 'button'
/** Unlike control's name label, this property is text on the button itself. */
text: string;
text: string
/** Called when the button is pressed. */
action: () => void;
action: () => void
}
export type Control =
| HrControl
| TextControl
| TextAreaControl
| RangeControl
| NumberControl
| CheckboxControl
| DropDownControl
| ButtonControl;
export type Control
= | HrControl
| TextControl
| TextAreaControl
| RangeControl
| NumberControl
| CheckboxControl
| DropDownControl
| ButtonControl
export type Controls = Control[];
export type Controls = Control[]

View File

@ -1,65 +1,90 @@
<script setup lang="ts">
import type { AudioTrack } from "@/lib/AudioTrack";
import * as Easing from "@/lib/easing";
import { ref } from "vue";
import type { Controls } from "../controls";
import ControlsView from "../controls/ControlsView.vue";
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
import type { Controls } from '../controls'
import type { AudioTrack } from '@/lib/AudioTrack'
import Explicit from '@material-design-icons/svg/filled/explicit.svg'
import { computed, ref } from 'vue'
import { LANGUAGES } from '@/lib/AudioTrack'
import * as Easing from '@/lib/easing'
import ControlsView from '../controls/ControlsView.vue'
const {
audioTrack,
} = 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",
key: "Name",
name: "Name",
kind: 'text',
key: 'Name',
name: 'Name',
ref: ref(audioTrack.Name),
readonly: true,
},
{
kind: "text",
key: "Artist",
name: "Artist",
kind: 'text',
key: 'Artist',
name: 'Artist',
ref: ref(audioTrack.Artist),
},
{
kind: "text",
key: "Song",
name: "Song",
kind: 'text',
key: 'Song',
name: 'Song',
ref: ref(audioTrack.Song),
disabled: true,
},
{
kind: "checkbox",
key: "IsExplicit",
name: "Is Explicit",
kind: 'hr',
key: 'audioTrack.hr.1',
},
{
kind: 'checkbox',
key: 'IsExplicit',
name: 'Is Explicit',
icon: Explicit,
ref: ref(audioTrack.IsExplicit),
label: "Explicit",
label: 'Explicit',
},
{
kind: "hr",
key: "audioTrack.hr.1",
kind: 'dropdown',
key: 'Language',
name: 'Language',
ref: ref(audioTrack.Language),
options: LANGUAGES,
},
{
kind: "range",
key: "BeatsOffset",
name: "Beats Offset",
kind: 'text',
key: 'Season',
name: 'Season',
ref: season,
},
{
kind: 'hr',
key: 'audioTrack.hr.2',
},
{
kind: 'range',
key: 'BeatsOffset',
name: 'Beats Offset',
min: -0.5,
max: 0.5,
default: 0,
ref: ref(audioTrack.BeatsOffset),
},
{
kind: "range",
key: "LoopOffset",
name: "Loop Offset",
kind: 'range',
key: 'LoopOffset',
name: 'Loop Offset',
disabled: true,
min: 0,
max: 128,
@ -67,115 +92,100 @@ const controls: Controls = [
ref: ref(audioTrack.LoopOffset),
},
{
kind: "dropdown",
key: "Easing",
name: "Easing",
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",
kind: 'number',
key: 'FadeOutBeat',
name: 'Fade Out Beat',
min: -1000,
max: 1000,
default: -2,
ref: ref(audioTrack.FadeOutBeat),
},
{
kind: "number",
key: "FadeOutDuration",
name: "Fade Out Duration",
kind: 'number',
key: 'FadeOutDuration',
name: 'Fade Out Duration',
min: 0,
max: 1000,
default: 2,
ref: ref(audioTrack.FadeOutDuration),
disabled: true,
},
// {
// kind: "number",
// key: "FadeOutDuration2",
// name: "Fade Out Duration",
// min: 0,
// max: 1000,
// default: 2,
// ref: ref(audioTrack.FadeOutDuration),
// readonly: true,
// },
// {
// kind: "number",
// key: "FadeOutDuration3",
// name: "Fade Out Duration",
// min: 0,
// max: 1000,
// default: 2,
// ref: ref(audioTrack.FadeOutDuration),
// disabled: true,
// readonly: true,
// },
];
{
kind: 'dropdown',
key: 'Easing',
name: 'Easing',
readonly: true,
ref: easing,
options: Easing.allNames,
},
{
kind: 'range',
key: 'TransitionIn',
name: 'Transition In',
disabled: true,
min: 0,
max: 1,
default: 0.25,
ref: ref(audioTrack.ColorTransitionIn),
},
{
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>
<template>
<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 />
</div>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { shallowRef, useTemplateRef } from 'vue';
import { shallowRef, useTemplateRef } from 'vue'
// Any card with a subtle outline and hover effect
const {
@ -7,84 +7,86 @@ const {
playheadEnabled = true,
selected = false,
} = defineProps<{
hoverEnabled?: boolean,
playheadEnabled?: boolean,
selected?: boolean,
}>();
hoverEnabled?: boolean
playheadEnabled?: boolean
selected?: boolean
}>()
const emit = defineEmits<{
(e: 'select'): void;
(e: 'activate'): void;
(e: 'playhead', pos: number): void;
}>();
(e: 'select'): void
(e: 'activate'): void
(e: 'playhead', pos: number): void
}>()
// Timeline / playhead position on hover in range 0..1,
// or NaN when not hovered or hover is not enabled.
const playheadPosition01 = shallowRef<number>(NaN);
const playheadEl = useTemplateRef('playheadEl');
const card = useTemplateRef('card');
const playheadPosition01 = shallowRef<number>(Number.NaN)
const playheadEl = useTemplateRef('playheadEl')
const card = useTemplateRef('card')
// 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'
const lastTapWasTouch = shallowRef(false);
const lastTapWasTouch = shallowRef(false)
// 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
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.
defineExpose({
playheadPosition01,
});
})
// Returns false if playhead wasn't updated
function updatePlayhead(event: PointerEvent): boolean {
const target = event.currentTarget as HTMLElement | null;
const target = event.currentTarget as HTMLElement | null
if (!hoverEnabled || !playheadEnabled || !target) {
return false;
return false
}
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const pos = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0;
playheadPosition01.value = pos;
const rect = target.getBoundingClientRect()
const x = event.clientX - rect.left
const pos = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0
playheadPosition01.value = pos
if (playheadEl.value) {
// 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('playhead', pos);
return true;
emit('playhead', pos)
return true
}
function onPointerEnter(_event: PointerEvent) {
if (hoverEnabled) {
isHovered.value = true;
isHovered.value = true
}
}
function onPointerLeave(_event: PointerEvent) {
if (hoverEnabled) {
isHovered.value = false;
isHovered.value = false
}
isTouchActive.value = false;
isTouchDragging.value = false;
playheadPosition01.value = NaN;
isTouchActive.value = false
isTouchDragging.value = false
playheadPosition01.value = Number.NaN
if (playheadEl.value) {
playheadEl.value.style.left = "";
playheadEl.value.style.left = ''
}
emit('playhead', NaN);
emit('playhead', Number.NaN)
}
function onPointerDown(event: PointerEvent) {
if (event.pointerType !== 'touch') return;
if (event.pointerType !== 'touch') {
return
}
if (updatePlayhead(event)) {
isTouchActive.value = true;
isTouchActive.value = true
}
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
if (event.pointerType === 'touch') {
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'
setTimeout(() => { lastTapWasTouch.value = false; }, 50);
setTimeout(() => {
lastTapWasTouch.value = false
}, 50)
// clear touch-active playhead state
isTouchActive.value = false;
isTouchDragging.value = false;
onPointerLeave(event);
isTouchActive.value = false
isTouchDragging.value = false
onPointerLeave(event)
}
}
function onPointerMove(event: PointerEvent) {
updatePlayhead(event);
updatePlayhead(event)
if (event.pointerType === 'touch') {
isTouchDragging.value = true;
isTouchDragging.value = true
}
}
function onClick(event: MouseEvent) {
// If this click follows a touch tap, suppress the 'select' event
if (lastTapWasTouch.value) {
event.preventDefault();
lastTapWasTouch.value = false;
return;
event.preventDefault()
lastTapWasTouch.value = false
return
}
emit('select');
emit('select')
}
function onDblClick(_event: MouseEvent) {
emit('activate')
}
</script>
<template>
<div ref="card" class="card card-border tw:min-w-10 tw:min-h-10 tw:grid" :class="{
'hover-enabled': hoverEnabled,
'playhead-enabled': hoverEnabled && playheadEnabled,
'playhead-active': isHovered || isTouchActive,
selected,
}" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @pointerdown="onPointerDown"
<div
ref="card" class="card card-border tw:min-w-10 tw:min-h-10 tw:grid" :class="{
'hover-enabled': hoverEnabled,
'playhead-enabled': hoverEnabled && playheadEnabled,
'playhead-active': isHovered || isTouchActive,
selected,
}" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @pointerdown="onPointerDown"
@pointerup="onPointerUp" @pointermove="onPointerMove" @click="onClick" @dblclick="onDblClick"
@focusin="emit('select')">
@focusin="emit('select')"
>
<!-- content container -->
<div class="tw:row-span-full tw:col-span-full tw:w-full tw:h-full">
<slot />
</div>
<!-- playhead container -->
<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>
</template>

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
</script>
<template>
<hr class="card-separator" />
<hr class="card-separator">
</template>
<style scoped>
@reference "tailwindcss";

View File

@ -1,13 +1,16 @@
<script setup lang="ts">
defineProps<{
color: string,
}>();
color: string
}>()
</script>
<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="{
backgroundColor: color,
}" />
}"
/>
</template>
<style scoped></style>

View File

@ -1,88 +1,108 @@
<script setup lang="ts">
import SectionHeader from '@/components/library/SectionHeader.vue';
import SearchField from '@/components/SearchField.vue';
import { type AudioTrack } from '@/lib/AudioTrack';
import { useTrackStore } from '@/store/TrackStore';
import FilterNone from '@material-design-icons/svg/outlined/filter_none.svg';
import { computed, ref, shallowRef } from 'vue';
import TrackCard from './TrackCard.vue';
import Footer from '../Footer.vue';
import type { AudioTrack } from '@/lib/AudioTrack'
import FilterNone from '@material-design-icons/svg/outlined/filter_none.svg'
import { computed, ref, shallowRef } from 'vue'
import SectionHeader from '@/components/library/SectionHeader.vue'
import SearchField from '@/components/SearchField.vue'
import { useTrackStore } from '@/store/TrackStore'
import Footer from '../Footer.vue'
import TrackCard from './TrackCard.vue'
const trackStore = useTrackStore();
const trackStore = useTrackStore()
const selectedTrackName = ref<string | null>(null);
// selectedTrackName.value = "HowLow";
const selectedTrackName = ref<string | null>(null)
const filterText = shallowRef("");
const filterText = shallowRef('')
const fuzzySubsequence = (needle: string, haystack: string): boolean => {
// 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++) {
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 q = filterText.value.trim().toLowerCase();
if (q === "") return true;
const q = filterText.value.trim().toLowerCase()
if (q === '') {
return true
}
// 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)
const fields: string[] = [];
const fields: string[] = []
const pushField = (v?: string) => {
if (typeof v === "string" && v.length > 0) {
fields.push(v.toLowerCase());
if (typeof v === 'string' && v.length > 0) {
fields.push(v.toLowerCase())
}
};
pushField(track.Name);
pushField(track.Artist);
pushField(track.Song);
}
pushField(track.Name)
pushField(track.Artist)
pushField(track.Song)
// for each token, require it to match at least one field (via includes or fuzzy match)
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(() => {
if (filterText.value === "") {
return trackStore.groupedSortedTracks;
} else {
if (filterText.value === '') {
return trackStore.groupedSortedTracks
}
else {
return trackStore.groupedSortedTracks
.map(([language, tracks]) => {
tracks = tracks.filter(trackMatches);
return [language, tracks] as const;
tracks = tracks.filter(trackMatches)
return [language, tracks] as const
})
// 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>
<template>
<div class="tw:flex tw:flex-col tw:h-full">
<!-- 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"
style="position: static;">
<div
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" />
</div>
<div v-if="filteredIsEmpty" class="tw:flex-1 tw:flex tw:flex-col tw:items-center tw:justify-center tw:gap-2"
style="color: #929292;">
<div
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" />
<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 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="
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>
<TrackCard v-for="track in tracks" :track :selected="track.Name === selectedTrackName"
@select="selectedTrackName = track.Name" />
"
>
<template v-for="[language, tracks] in filteredGroupedSortedTracks" :key="language">
<SectionHeader class="tw:col-span-full">
{{ language }}
</SectionHeader>
<TrackCard
v-for="track in tracks"
:key="track.Name"
:track
:selected="track.Name === selectedTrackName"
@select="selectedTrackName = track.Name"
/>
</template>
</div>
<Footer />

View File

@ -1,11 +1,12 @@
<script setup lang="ts">
</script>
<template>
<div>
<h2 class="tw:text-3xl tw:p-4 tw:pb-2 tw:text-center">
<slot />
</h2>
<hr class="section-header__separator" />
<hr class="section-header__separator">
</div>
</template>

View File

@ -1,2 +1,2 @@
/** Slider's orientation */
export type Orientation = "horizontal" | "vertical";
export type Orientation = 'horizontal' | 'vertical'

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, useAttrs, useId } from 'vue';
import classes from './ToolBar.module.css';
import type { Orientation } from "./Slider";
import type { Orientation } from './Slider'
import { computed, useAttrs, useId } from 'vue'
import classes from './ToolBar.module.css'
defineOptions({ inheritAttrs: false })
const {
min,
@ -9,45 +11,48 @@ const {
step,
defaultValue,
reset,
orientation = "horizontal",
orientation = 'horizontal',
title,
} = defineProps<{
min?: number,
max?: number,
step?: number,
defaultValue?: number,
reset?: () => void,
orientation?: Orientation,
title?: string,
}>();
min?: number
max?: number
step?: number
defaultValue?: number
reset?: () => void
orientation?: Orientation
title?: string
}>()
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const attrs = useAttrs()
const isVertical = computed(() => orientation === "vertical");
const orient = computed(() => orientation === "vertical" ? "vertical" : null);
const isVertical = computed(() => orientation === 'vertical')
const orient = computed(() => orientation === 'vertical' ? 'vertical' : null)
const model = defineModel<number>();
const model = defineModel<number>()
function dblclickHandler(event: MouseEvent) {
if (reset !== undefined) {
event.preventDefault();
reset();
event.preventDefault()
reset()
}
}
const markersListId = useId();
const markersListId = useId()
</script>
<template>
<input type="range" :min :max :step v-model.number="model" :orient :title @dblclick="dblclickHandler"
class="slider tw:flex-1 tw:basis-20"
<input
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']"
: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 -->
<datalist :id="markersListId">
<option v-if="defaultValue !== undefined" :value="defaultValue"></option>
<option v-if="defaultValue !== undefined" :value="defaultValue" />
</datalist>
</template>
<style scoped>
.slider {
-webkit-appearance: none;
@ -126,7 +131,6 @@ const markersListId = useId();
margin-left: -4px;
}
.slider::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;

View File

@ -42,7 +42,10 @@
}
.toolbar-toggle {
/* prevent CSS minifier/Tailwind purge from removing this rule */
--toolbar-toggle-keep: 0;
}
.toolbar-toggle-checked {
color: white;

View File

@ -1,15 +1,16 @@
<script setup lang="ts">
import type { Component } from 'vue';
import classes from './ToolBar.module.css';
import type { Component } from 'vue'
import classes from './ToolBar.module.css'
defineProps<{
icon: string | Component,
}>();
icon: string | Component
}>()
</script>
<template>
<button type="button" :class="[classes.toolButton, classes.toolbarControl]">
<component :is="icon" />
</button>
</template>
<style scoped></style>

View File

@ -1,17 +1,18 @@
<script setup lang="ts">
import type { Component } from 'vue';
import classes from './ToolBar.module.css';
import type { Component } from 'vue'
import classes from './ToolBar.module.css'
defineProps<{
icon: string | Component,
disabled?: boolean,
}>();
icon: string | Component
disabled?: boolean
}>()
</script>
<template>
<button type="button" :class="[classes.toolButton, classes.toolButtonSmall]" :disabled>
<component :is="icon" />
</button>
</template>
<style scoped>
</style>

View File

@ -1,17 +1,25 @@
<script setup lang="ts">
import type { Component } from 'vue';
import classes from './ToolBar.module.css';
import type { Component } from 'vue'
import classes from './ToolBar.module.css'
defineProps<{
checked: boolean,
icon: string | Component,
}>();
checked: boolean
icon: string | Component
}>()
</script>
<template>
<button type="button"
:class="[classes.toolButton, classes.toolbarControl, classes.toolbarToggle, checked ? classes.toolbarToggleChecked : undefined]">
<button
type="button"
:class="[
classes.toolButton,
classes.toolbarControl,
classes.toolbarToggle,
checked ? classes.toolbarToggleChecked : undefined,
]"
>
<component :is="icon" />
</button>
</template>
<style scoped></style>

View File

@ -1,54 +1,61 @@
<script setup lang="ts">
import Card from '@/components/library/Card.vue';
import ColorSwatch from '@/components/library/ColorSwatch.vue';
import { openTrack } from '@/router';
import { formatTime, timeSeriesIsEmpty, type AudioTrack } from '@/lib/AudioTrack';
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
import Lyrics from '@material-design-icons/svg/filled/lyrics.svg';
import AutoAwesome from '@material-design-icons/svg/filled/auto_awesome.svg';
import LibraryMusic from '@material-design-icons/svg/two-tone/library_music.svg';
import { computed, shallowRef } from 'vue';
import TrackCardBadge from './TrackCardBadge.vue';
import CardSeparator from './CardSeparator.vue';
import type { AudioTrack } from '@/lib/AudioTrack'
import AutoAwesome from '@material-design-icons/svg/filled/auto_awesome.svg'
import Explicit from '@material-design-icons/svg/filled/explicit.svg'
import Lyrics from '@material-design-icons/svg/filled/lyrics.svg'
import Event from '@material-design-icons/svg/two-tone/event.svg'
import LibraryMusic from '@material-design-icons/svg/two-tone/library_music.svg'
import { computed, shallowRef } from 'vue'
import Card from '@/components/library/Card.vue'
import ColorSwatch from '@/components/library/ColorSwatch.vue'
import { formatTime, timeSeriesIsEmpty } from '@/lib/AudioTrack'
import { openTrack } from '@/router'
import CardSeparator from './CardSeparator.vue'
import TrackCardBadge from './TrackCardBadge.vue'
const {
track,
} = 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>(() =>
timeSeriesIsEmpty(track.DrunknessLoopOffsetTimeSeries) ||
timeSeriesIsEmpty(track.CondensationLoopOffsetTimeSeries)
);
timeSeriesIsEmpty(track.DrunknessLoopOffsetTimeSeries)
|| 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)
const previewColor = shallowRef<string | undefined>(undefined);
const previewColor = shallowRef<string | undefined>(undefined)
function updatePreview(pos: number) {
if (!track || !track.Palette || track.Palette.length === 0) return;
if (!isFinite(pos)) {
if (!track || !track.Palette || track.Palette.length === 0) {
return
}
if (!Number.isFinite(pos)) {
// reset to default
previewColor.value = "";
return;
previewColor.value = ''
return
}
// pick an index in the palette (wrap)
const n = track.Palette.length;
const idx = Math.floor(pos * n) % n;
previewColor.value = track.Palette[idx] || undefined;
const n = track.Palette.length
const idx = Math.floor(pos * n) % n
previewColor.value = track.Palette[idx] || undefined
}
</script>
<template>
<Card class="card" tabindex="0" @activate="openTrack(track)" @playhead="(pos) => updatePreview(pos)">
<div class="card-grid tw:grid tw:p-2 tw:gap-2">
<!-- preview -->
<!-- square aspect trick didn't work in grid, reverted to fixed size -->
<div 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">
<div
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 }" />
</div>
@ -60,6 +67,7 @@ function updatePreview(pos: number) {
</h3>
<TrackCardBadge v-if="track.IsExplicit" :icon="Explicit" title="Warning: Explicit Content" />
<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="hasEffects" :icon="AutoAwesome" title="Contains Visual Effects" />
</div>
@ -75,19 +83,25 @@ function updatePreview(pos: number) {
<!-- palette -->
<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>
<!-- timing -->
<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="Loop offset" class="tw:font-mono" v-if="track.LoopOffset > 0">{{ track.LoopOffset }} beats</div>
<div title="Loop duration" class="tw:font-mono">{{ formatTime(track.FileDurationLoop) }}</div>
<div title="Intro duration" class="tw:font-mono">
{{ formatTime(track.WindUpTimer) }}
</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>
</Card>
</template>
<style scoped>
@reference "tailwindcss";
@ -125,4 +139,4 @@ function updatePreview(pos: number) {
.inactive-color {
color: var(--inactive-text-color);
}
</style>
</style>

View File

@ -1,15 +1,17 @@
<script setup lang="ts">
import type { Component } from 'vue';
import type { Component } from 'vue'
defineProps<{
icon: string | Component,
title: string,
}>();
icon: string | Component
title: string
}>()
</script>
<template>
<div :title class="tw:flex-none tw:z-10">
<component :is="icon" class="tw:w-6 tw:h-6 tw:fill-current" />
</div>
</template>
<style scoped>
</style>

View File

@ -1,73 +1,93 @@
<script setup lang="ts">
import Slider from '@/components/library/Slider.vue';
import VolumeDown from '@material-design-icons/svg/outlined/volume_down.svg';
import VolumeMute from '@material-design-icons/svg/outlined/volume_mute.svg';
import VolumeOff from '@material-design-icons/svg/outlined/volume_off.svg';
import VolumeUp from '@material-design-icons/svg/outlined/volume_up.svg';
import { computed, useId } from 'vue';
import classes from './ToolBar.module.css';
import VolumeDown from '@material-design-icons/svg/outlined/volume_down.svg'
import VolumeMute from '@material-design-icons/svg/outlined/volume_mute.svg'
import VolumeOff from '@material-design-icons/svg/outlined/volume_off.svg'
import VolumeUp from '@material-design-icons/svg/outlined/volume_up.svg'
import { computed, useId } from 'vue'
import Slider from '@/components/library/Slider.vue'
import classes from './ToolBar.module.css'
const {
defaultVolume = 1,
enableBoost = true,
} = defineProps<{
defaultVolume?: number,
defaultVolume?: number
// 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. */
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. */
const volume = defineModel<number>('volume', { required: true });
const volume = defineModel<number>('volume', { required: true })
const mutedId = useId();
const mutedTitle = computed(() => muted.value ? 'Unmute' : 'Mute');
const mutedId = useId()
const mutedTitle = computed(() => muted.value ? 'Unmute' : 'Mute')
function toggleMuted() {
muted.value = !muted.value;
muted.value = !muted.value
}
const volumeMax = computed(() => enableBoost ? 1.5 : 1.0);
const sliderSteps = computed(() => enableBoost ? 24 : 16);
const volumeMax = computed(() => enableBoost ? 1.5 : 1.0)
const sliderSteps = computed(() => enableBoost ? 24 : 16)
function toSteps(volume: number): number {
return volume / volumeMax.value * sliderSteps.value;
return volume / volumeMax.value * sliderSteps.value
}
function fromSteps(steps: number): number {
return steps / sliderSteps.value * volumeMax.value;
return steps / sliderSteps.value * volumeMax.value
}
const volumeDisplay = computed<number>({
get() {
// 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) {
volume.value = fromSteps(value);
volume.value = fromSteps(value)
},
});
})
function reset() {
volume.value = defaultVolume;
volume.value = defaultVolume
}
const defaultValue = computed(() => toSteps(defaultVolume));
const defaultValue = computed(() => toSteps(defaultVolume))
</script>
<template>
<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" />
<label :for="mutedId" :class="[classes.toolbarControl, classes.toolButton]" :title="mutedTitle" tabindex="0">
<input
: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]" />
<!-- transforms are needed because icons are centered rather than aligned with each other -->
<VolumeMute v-else-if="volume < 0.33" style="transform: translateX(-8px);" />
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
</label>
<Slider :min="0" :max="sliderSteps" :step="1" v-model.number="volumeDisplay" :reset="reset" :defaultValue
title="Volume" />
<Slider
v-model.number="volumeDisplay"
:min="0"
:max="sliderSteps"
:step="1"
:reset="reset"
:default-value
title="Volume"
/>
</div>
</template>
<style scoped></style>

View File

@ -1,32 +1,40 @@
<script setup lang="ts">
import Slider from '@/components/library/Slider.vue';
import type { UseZoomAxis } from '@/lib/useZoomAxis';
import Add from "@material-design-icons/svg/filled/add.svg";
import Remove from "@material-design-icons/svg/filled/remove.svg";
import type { Orientation } from "./Slider";
import ToolButtonSmall from './ToolButtonSmall.vue';
import type { Orientation } from './Slider'
import type { UseZoomAxis } from '@/lib/useZoomAxis'
import Add from '@material-design-icons/svg/filled/add.svg'
import Remove from '@material-design-icons/svg/filled/remove.svg'
import Slider from '@/components/library/Slider.vue'
import ToolButtonSmall from './ToolButtonSmall.vue'
const {
axis,
orientation = "horizontal",
orientation = 'horizontal',
} = defineProps<{
axis: UseZoomAxis,
orientation?: Orientation,
}>();
axis: UseZoomAxis
orientation?: Orientation
}>()
</script>
<!-- for some reason min-width does not propagate up from Slider -->
<template>
<div 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" />
<div
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" :disabled="axis.isAtMin.value" @click="axis.zoomOut" />
<!-- 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"
v-model.number="axis.zoom.discrete.value" :orientation :reset="axis.reset" />
<Slider
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>
</template>
<style scoped></style>

View File

@ -1,10 +1,9 @@
<script setup lang="ts">
import ToolBar from "./ToolBar.vue";
import ToolBar from './ToolBar.vue'
</script>
<template>
<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">
<slot name="toolbar" />
</ToolBar>

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import Panel from "./Panel.vue";
import ShadowedScrollView from "./ShadowedScrollView.vue";
import Panel from './Panel.vue'
import ShadowedScrollView from './ShadowedScrollView.vue'
</script>
<template>

View File

@ -1,13 +1,14 @@
<script setup lang="ts">
import { useInterval, useScroll } from "@vueuse/core";
import { useTemplateRef } from "vue";
import { useScroll } from '@vueuse/core'
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,
// see: https://github.com/vueuse/vueuse/issues/4265#issuecomment-3618168624
// const { arrivedState, measure } = useScroll(scrollView)
// useInterval(2000, {
// callback: () => {
// console.log("MEASURE");
@ -25,25 +26,24 @@ const { arrivedState, measure } = useScroll(scrollView);
<!-- 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;">
<!-- 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:h-4 tw:w-full shadow-bottom"></div>
<div class="tw:h-4 tw:w-full shadow-bottom" />
</div>
<!-- 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:h-4 tw:w-full shadow-top"></div>
<div class="tw:h-4 tw:w-full shadow-top" />
</div>
<!-- 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:w-4 tw:h-full shadow-right"></div>
<div class="tw:w-4 tw:h-full shadow-right" />
</div>
<!-- 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:w-4 tw:h-full shadow-left"></div>
<div class="tw:w-4 tw:h-full shadow-left" />
</div>
</div>
</div>

View File

@ -1,44 +1,48 @@
<script lang="ts">
import mitt, { type Handler } from "mitt";
import { useRafFn } from "@vueuse/core";
import { onMounted, onBeforeUnmount } from "vue";
import type { Handler } from 'mitt'
import { useRafFn } from '@vueuse/core'
import mitt from 'mitt'
// eslint-disable-next-line import/no-duplicates
import { onBeforeUnmount, onMounted } from 'vue'
interface ScrollSyncEvent {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
scrollLeft: number;
scrollWidth: number;
clientWidth: number;
barHeight: number;
barWidth: number;
emitter: string;
group: string;
scrollTop: number
scrollHeight: number
clientHeight: number
scrollLeft: number
scrollWidth: number
clientWidth: number
barHeight: number
barWidth: number
emitter: string
group: string
}
type Events = {
"scroll-sync": ScrollSyncEvent,
};
'scroll-sync': ScrollSyncEvent
}
const emitter = mitt<Events>();
const emitter = mitt<Events>()
function useEvent<Key extends keyof Events>(
type: Key,
handler: Handler<Events[Key]>,
): void {
const { on, off } = emitter;
const { on, off } = emitter
onMounted(() => {
on(type, handler);
});
on(type, handler)
})
onBeforeUnmount(() => {
off(type, handler);
});
off(type, handler)
})
}
</script>
<script setup lang="ts">
import { useId, useTemplateRef } from "vue";
// eslint-disable-next-line import/first, import/no-duplicates
import { useId, useTemplateRef } from 'vue'
const {
proportional,
@ -46,18 +50,18 @@ const {
horizontal,
group,
} = defineProps<{
proportional?: boolean,
vertical?: boolean,
horizontal?: boolean,
group: string,
}>();
proportional?: boolean
vertical?: boolean
horizontal?: boolean
group: string
}>()
const uuid = useId();
const nodeRef = useTemplateRef("scroll-sync-container");
const uuid = useId()
const nodeRef = useTemplateRef('scroll-sync-container')
defineExpose({
scrollTo: (options: ScrollToOptions) => {
nodeRef.value?.scrollTo(options);
nodeRef.value?.scrollTo(options)
},
})
@ -71,8 +75,8 @@ function handleScroll(event: Event) {
scrollWidth,
clientWidth,
offsetHeight,
offsetWidth
} = event.target as HTMLElement;
offsetWidth,
} = event.target as HTMLElement
emitter.emit('scroll-sync', {
scrollTop,
@ -85,15 +89,15 @@ function handleScroll(event: Event) {
barWidth: offsetWidth - clientWidth,
emitter: uuid,
group,
});
}, { once: true });
})
}, { once: true })
}
useEvent("scroll-sync", (event: ScrollSyncEvent) => {
const node = nodeRef.value;
useEvent('scroll-sync', (event: ScrollSyncEvent) => {
const node = nodeRef.value
if (event.group !== group || event.emitter === uuid || node === null) {
return;
return
}
const {
@ -104,40 +108,42 @@ useEvent("scroll-sync", (event: ScrollSyncEvent) => {
scrollWidth,
clientWidth,
barHeight,
barWidth
} = event;
barWidth,
} = event
// from https://github.com/okonet/react-scroll-sync
const scrollTopOffset = scrollHeight - clientHeight;
const scrollLeftOffset = scrollWidth - clientWidth;
const scrollTopOffset = scrollHeight - clientHeight
const scrollLeftOffset = scrollWidth - clientWidth
/* Calculate the actual pane height */
const paneHeight = node.scrollHeight - clientHeight;
const paneWidth = node.scrollWidth - clientWidth;
const paneHeight = node.scrollHeight - clientHeight
const paneWidth = node.scrollWidth - clientWidth
/* Adjust the scrollTop position of it accordingly */
node.removeEventListener("scroll", handleScroll);
node.removeEventListener('scroll', handleScroll)
if (vertical && scrollTopOffset > barHeight) {
node.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop;
node.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop
}
if (horizontal && scrollLeftOffset > barWidth) {
node.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft;
node.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft
}
useRafFn(() => {
node.addEventListener("scroll", handleScroll);
}, { once: true });
});
node.addEventListener('scroll', handleScroll)
}, { once: true })
})
onMounted(() => {
const node = nodeRef.value;
node!.addEventListener("scroll", handleScroll);
});
const node = nodeRef.value
node!.addEventListener('scroll', handleScroll)
})
</script>
<template>
<div ref="scroll-sync-container" class="scroll-sync-container">
<slot></slot>
<slot />
</div>
</template>
<style scoped>
.scroll-sync-container {
overflow: auto;

View File

@ -1,30 +1,32 @@
<script setup lang="ts">
import VolumeSlider from '@/components/library/VolumeSlider.vue';
import { useTrackStore } from '@/store/TrackStore';
import { computed } from 'vue';
import { computed } from 'vue'
import VolumeSlider from '@/components/library/VolumeSlider.vue'
import { useTrackStore } from '@/store/TrackStore'
const trackStore = useTrackStore();
const trackStore = useTrackStore()
const muted = computed<boolean>({
get() {
return trackStore.muted;
return trackStore.muted
},
set(muted: boolean) {
trackStore.setMuted(muted);
trackStore.setMuted(muted)
},
});
})
const volume = computed<number>({
get() {
return trackStore.volume;
return trackStore.volume
},
set(volume: number) {
trackStore.setVolume(volume);
trackStore.setVolume(volume)
},
});
})
</script>
<template>
<VolumeSlider v-model:muted="muted" v-model:volume="volume" />
</template>
<style scoped>
</style>

View File

@ -1,8 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useTimelineStore } from '@/store/TimelineStore';
const timeline = useTimelineStore();
import { computed } from 'vue'
import { useTimelineStore } from '@/store/TimelineStore'
const {
positionSeconds,
@ -10,24 +8,30 @@ const {
hidden = false,
} = defineProps<{
// position in absolute seconds
positionSeconds: number,
knob?: boolean,
hidden?: boolean,
}>();
positionSeconds: number
knob?: boolean
hidden?: boolean
}>()
const positionPixels = computed(() => timeline.secondsToPixels(positionSeconds));
const viewportSide = computed(() => timeline.viewportSide(positionSeconds));
const timeline = useTimelineStore()
const positionPixels = computed(() => timeline.secondsToPixels(positionSeconds))
const viewportSide = computed(() => timeline.viewportSide(positionSeconds))
</script>
<template>
<div class="playhead" :style="{
'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" />
<img src="@/assets/playhead-main.png" class="tw:flex-1" />
<div
class="playhead" :style="{
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 v-if="knob" src="@/assets/playhead-top.png" class="tw:flex-none">
<img src="@/assets/playhead-main.png" class="tw:flex-1">
</div>
<!-- slot container -->

View File

@ -1,103 +1,108 @@
<script setup lang="ts">
import ZoomSlider from '@/components/library/ZoomSlider.vue';
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
import Playhead from '@/components/timeline/Playhead.vue';
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
import { useTimelineScrubbing } from "@/lib/useTimelineScrubbing";
import { useVeiwportWheel } from '@/lib/useVeiwportWheel';
import type { UseZoomAxis } from '@/lib/useZoomAxis';
import { bindTwoWay, toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
import { useElementBounding, useScroll } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { useId, useTemplateRef, watch } from 'vue';
import TimelineTrackHeader from './TimelineTrackHeader.vue';
import TimelineTrackView from './TimelineTrackView.vue';
import TimelineMarkers from './markers/TimelineMarkers.vue';
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState'
import type { UseZoomAxis } from '@/lib/useZoomAxis'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useId, useTemplateRef, watch } from 'vue'
import ZoomSlider from '@/components/library/ZoomSlider.vue'
import ScrollSync from '@/components/scrollsync/ScrollSync.vue'
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue'
import Playhead from '@/components/timeline/Playhead.vue'
import { onInputKeyStroke } from '@/lib/onInputKeyStroke'
import { useTimelineScrubbing } from '@/lib/useTimelineScrubbing'
import { useVeiwportWheel } from '@/lib/useVeiwportWheel'
import { bindTwoWay, toPx } from '@/lib/vue'
import { useTimelineStore } from '@/store/TimelineStore'
import TimelineMarkers from './markers/TimelineMarkers.vue'
import TimelineTrackHeader from './TimelineTrackHeader.vue'
import TimelineTrackView from './TimelineTrackView.vue'
const {
rightSidebar,
} = defineProps<{
rightSidebar: UseOptionalWidgetStateReturn,
}>();
rightSidebar: UseOptionalWidgetStateReturn
}>()
const timeline = useTimelineStore();
const timeline = useTimelineStore()
const {
headerHeight, sidebarWidth,
viewportScrollOffsetTop, viewportScrollOffsetLeft,
headerHeight,
sidebarWidth,
viewportScrollOffsetTop,
viewportScrollOffsetLeft,
contentWidthIncludingEmptySpacePx,
visibleTracks,
} = storeToRefs(timeline);
} = storeToRefs(timeline)
// nested composable marked with markRaw
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis;
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis;
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
const timelineScrollGroup = useId();
const timelineScrollGroup = useId()
const timelineRootElement = useTemplateRef('timelineRootElement');
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView');
const timelineScrollViewBounding = useElementBounding(timelineScrollView);
const timelineRootElement = useTemplateRef('timelineRootElement')
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView')
const timelineScrollViewBounding = useElementBounding(timelineScrollView)
watch(timelineScrollViewBounding.width, (value) => {
timeline.viewportWidth = value;
});
timeline.viewportWidth = value
})
watch(timelineScrollViewBounding.height, (value) => {
timeline.viewportHeight = value;
});
timeline.viewportHeight = value
})
const {
arrivedState: timelineScrollViewArrivedState,
x: timelineScrollViewOffsetLeft,
y: timelineScrollViewOffsetTop,
} = useScroll(() => timelineScrollView.value?.$el);
} = useScroll(() => timelineScrollView.value?.$el)
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop);
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft);
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop)
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft)
useVeiwportWheel(timelineRootElement, {
axisHorizontal: viewportZoomHorizontal,
axisVertical: viewportZoomVertical,
scrollOffsetLeft: timelineScrollViewOffsetLeft,
});
})
// Shift+Z - reset zoom
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
timeline.zoomToggleBetweenWholeAndLoop();
event.preventDefault();
});
onInputKeyStroke(event => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
timeline.zoomToggleBetweenWholeAndLoop()
event.preventDefault()
})
const scrubbing = useTemplateRef('scrubbing');
useTimelineScrubbing(scrubbing);
const scrubbing = useTemplateRef('scrubbing')
useTimelineScrubbing(scrubbing)
</script>
<template>
<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-rows': `${toPx(headerHeight)} 1fr`,
}">
<template>
<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-rows': `${toPx(headerHeight)} 1fr`,
}"
>
<!-- 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"
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);">
<div
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" />
</div>
<!-- left sidebar with timeline track names -->
<ScrollSync :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 />
<ScrollSync
: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" :key="timelineTrack.name">
<TimelineTrackHeader :timeline-track />
</template>
</ScrollSync>
<!-- header with timestamps -->
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);">
<ScrollSync
: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 }">
<TimelineHeader />
</div>
@ -105,63 +110,63 @@ useTimelineScrubbing(scrubbing);
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
<!-- </Playhead> -->
</ScrollSync>
<!-- timeline content -->
<ScrollSync ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;">
<ScrollSync
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 -->
<div class="tw:relative tw:overflow-hidden tw:min-h-full"
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }">
<div
class="tw:relative tw:overflow-hidden tw:min-h-full"
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }"
>
<!-- timeline markers -->
<TimelineMarkers />
<!-- timeline tracks -->
<div>
<template v-for="timelineTrack in visibleTracks">
<TimelineTrackView :timelineTrack />
<template v-for="timelineTrack in visibleTracks" :key="timelineTrack.name">
<TimelineTrackView :timeline-track />
</template>
</div>
</div>
</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:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.top }">
<div class="tw:h-4 tw:w-full shadow-bottom"></div>
<div
class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.top }"
>
<div class="tw:h-4 tw:w-full shadow-bottom" />
</div>
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.bottom }">
<div class="tw:h-4 tw:w-full shadow-top"></div>
<div
class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.bottom }"
>
<div class="tw:h-4 tw:w-full shadow-top" />
</div>
</div>
<!-- playhead -->
<ScrollSync :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 }">
<ScrollSync
: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 }"
>
<!-- 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" /> -->
</Playhead>
</div>
</ScrollSync>
<!-- cursor on hover -->
<!-- <Playhead :position="cursorPosition" :timelineWidth="timelineWidth" :knob="false"
:hidden="cursorPositionSeconds === null || isDragging">
@ -170,34 +175,38 @@ useTimelineScrubbing(scrubbing);
<!-- 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:absolute tw:top-0 tw:left-0 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.left }">
<div class="tw:w-4 tw:h-full shadow-right"></div>
<div
class="tw:absolute tw:top-0 tw:left-0 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.left }"
>
<div class="tw:w-4 tw:h-full shadow-right" />
</div>
<div class="tw:absolute tw:top-0 tw:right-4 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.right }">
<div class="tw:w-4 tw:h-full shadow-left"></div>
<div
class="tw:absolute tw:top-0 tw:right-4 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.right }"
>
<div class="tw:w-4 tw:h-full shadow-left" />
</div>
</div>
<!-- empty cell at the top right -->
<div v-if="rightSidebar.visible.value" class="toolbar-background"
style="grid-row: 1; grid-column: 3; border-bottom: var(--view-separator-border); border-left: var(--view-separator-border);">
</div>
<div
v-if="rightSidebar.visible.value" class="toolbar-background"
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 -->
<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"
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" />
</div>
</div>
</template>
<style scoped>
.shadow-top,
.shadow-right,

View File

@ -1,60 +1,66 @@
<script setup lang="ts">
import { togglePlayStop } from '@/audio/AudioEngine';
import ToolButton from '@/components/library/ToolButton.vue';
import ToolToggle from '@/components/library/ToolToggle.vue';
import Timestamp from '@/components/timeline/Timestamp.vue';
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState';
import { useTimelineStore } from '@/store/TimelineStore';
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg';
import Play from '@material-design-icons/svg/outlined/play_circle.svg';
import Replay from '@material-design-icons/svg/outlined/replay.svg';
import Restart from '@material-design-icons/svg/outlined/restart_alt.svg';
import ViewSidebar from '@material-design-icons/svg/outlined/view_sidebar.svg';
import { useLocalStorage } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import MasterVolumeSlider from './MasterVolumeSlider.vue';
import Timeline from './Timeline.vue';
import Panel from "@/components/library/panel/Panel.vue";
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg'
import Play from '@material-design-icons/svg/outlined/play_circle.svg'
import Replay from '@material-design-icons/svg/outlined/replay.svg'
import Restart from '@material-design-icons/svg/outlined/restart_alt.svg'
import ViewSidebar from '@material-design-icons/svg/outlined/view_sidebar.svg'
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { togglePlayStop } from '@/audio/AudioEngine'
import Panel from '@/components/library/panel/Panel.vue'
import ToolButton from '@/components/library/ToolButton.vue'
import ToolToggle from '@/components/library/ToolToggle.vue'
import Timestamp from '@/components/timeline/Timestamp.vue'
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState'
import { useTimelineStore } from '@/store/TimelineStore'
import MasterVolumeSlider from './MasterVolumeSlider.vue'
import Timeline from './Timeline.vue'
const timeline = useTimelineStore();
const { audioTrack, isPlaying } = storeToRefs(timeline);
const timeline = useTimelineStore()
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.
// Not sure I want this to remain, so used a boolean flag to hide.
const rightSidebar = useOptionalWidgetState({
visible: useLocalStorage("timeline.rightSidebar.visible", true),
showString: "Show Right Sidebar",
hideString: "Hide Right Sidebar",
visible: useLocalStorage('timeline.rightSidebar.visible', true),
showString: 'Show Right Sidebar',
hideString: 'Hide Right Sidebar',
width: 32,
});
})
function rewindToIntro() {
timeline.rewindToIntro();
timeline.rewindToIntro()
}
function rewindToWindUp() {
timeline.rewindToWindUp();
timeline.rewindToWindUp()
}
function rewindToLoop() {
timeline.rewindToLoop();
timeline.rewindToLoop()
}
function toggle() {
togglePlayStop(timeline.player, { rememberPosition: true });
togglePlayStop(timeline.player, { rememberPosition: true })
}
</script>
<template>
<Panel class="tw:border-t">
<template #toolbar>
<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
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"
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
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" title="Rewind to Intro" @click="rewindToIntro" />
<ToolButton
: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" />
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
</div>
@ -64,16 +70,18 @@ function toggle() {
{{ audioTrack?.Name }}
</div>
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
<ToolToggle :checked="rightSidebar.visible.value" :icon="ViewSidebar" @click="rightSidebar.toggle()"
:title="rightSidebar.toggleActionString.value" />
<ToolToggle
:checked="rightSidebar.visible.value" :icon="ViewSidebar" :title="rightSidebar.toggleActionString.value"
@click="rightSidebar.toggle()"
/>
</div>
</div>
</template>
<Timeline class="tw:min-h-0 tw:size-full" :rightSidebar />
<Timeline class="tw:min-h-0 tw:size-full" :right-sidebar />
</Panel>
</template>
<style scoped>
.description {
flex: 1 1 auto;

View File

@ -1,44 +1,45 @@
<script setup lang="ts">
import type { TimelineTrackData } from '@/lib/Timeline';
import { toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import type { TimelineTrackData } from '@/lib/Timeline'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { toPx } from '@/lib/vue'
import { useTimelineStore } from '@/store/TimelineStore'
const {
timelineTrack,
} = 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([
["zero", "Clips"],
["one", "Clip"],
["two", "Clips"],
["few", "Clips"],
["other", "Clips"],
]);
['zero', 'Clips'],
['one', 'Clip'],
['two', 'Clips'],
['few', 'Clips'],
['other', 'Clips'],
])
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>
<template>
<!-- border-bottom -->
<div class="tw:w-full" style="border-bottom: var(--view-separator-border);" :style="{ height: toPx(trackHeight) }">
<!-- horizontal layout -->
<div class="tw:size-full tw:flex tw:flex-row">
<!-- left color strip -->
<div class="tw:flex-none tw:w-1 tw:h-full tw:border-r" style="border-right: var(--view-separator-border);"
:style="{ backgroundColor: timelineTrack.color }" />
<div
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 -->
<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>
</template>
<style scoped></style>

View File

@ -1,34 +1,39 @@
<script setup lang="ts">
import type { TimelineTrackData } from '@/lib/Timeline';
import { toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
import { storeToRefs } from 'pinia';
import TimelineClipView from './clip/TimelineClipView.vue';
import type { TimelineTrackData } from '@/lib/Timeline'
import { storeToRefs } from 'pinia'
import { toPx } from '@/lib/vue'
import { useTimelineStore } from '@/store/TimelineStore'
import TimelineClipView from './clip/TimelineClipView.vue'
const {
timelineTrack,
} = defineProps<{
timelineTrack: TimelineTrackData,
}>();
const timeline = useTimelineStore();
const { trackHeight, contentWidthIncludingEmptySpacePx } = storeToRefs(timeline);
timelineTrack: TimelineTrackData
}>()
const timeline = useTimelineStore()
const { trackHeight, contentWidthIncludingEmptySpacePx } = storeToRefs(timeline)
</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 -->
<div 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);" />
<div
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 -->
<div class="tw:size-full" style="grid-row: 1; grid-column: 1; position: relative;">
<template v-for="timelineClip in timelineTrack.clips">
<TimelineClipView :timelineTrack :timelineClip />
<!-- TODO: use clip id -->
<template v-for="timelineClip in timelineTrack.clips" :key="timelineClip.clipIn">
<TimelineClipView :timeline-track :timeline-clip />
</template>
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,16 +1,20 @@
<script setup lang="ts">
import { formatBeats, formatTime } from '@/lib/AudioTrack';
import { formatBeats, formatTime } from '@/lib/AudioTrack'
defineProps<{
seconds: number,
beats: number,
seconds: number
beats: number
}>()
</script>
<template>
<div class="timestamp">
<div title="seconds">{{ formatTime(seconds) }}</div>
<div title="beats">{{ formatBeats(beats) }}</div>
<div title="seconds">
{{ formatTime(seconds) }}
</div>
<div title="beats">
{{ formatBeats(beats) }}
</div>
</div>
</template>

View File

@ -1,44 +1,45 @@
<script setup lang="ts">
import { timelineClipColor, toAbsoluteDuration, toAbsoluteTime, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
import { toPx, usePx } from '@/lib/usePx';
import { useTimelineStore } from '@/store/TimelineStore';
import { useCssVar, useElementBounding } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { computed, shallowRef, useTemplateRef } from 'vue';
import { getComponentFor } from '.';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import { useCssVar, useElementBounding } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, shallowRef, useTemplateRef } from 'vue'
import { timelineClipColor, toAbsoluteDuration, toAbsoluteTime } from '@/lib/Timeline'
import { toPx, usePx } from '@/lib/usePx'
import { useTimelineStore } from '@/store/TimelineStore'
import { getComponentFor } from '.'
const {
timelineTrack,
timelineClip,
} = defineProps<{
timelineTrack: TimelineTrackData,
timelineClip: TimelineClipData,
}>();
timelineTrack: TimelineTrackData
timelineClip: TimelineClipData
}>()
const timeline = useTimelineStore();
const { audioTrack } = storeToRefs(timeline);
const timeline = useTimelineStore()
const { audioTrack } = storeToRefs(timeline)
const contentView = computed(() => getComponentFor(timelineTrack));
const contentView = computed(() => getComponentFor(timelineTrack))
const left = computed(() => {
const t = toAbsoluteTime(audioTrack.value, timelineTrack.reference, timelineClip.clipIn);
const px = timeline.secondsToPixels(t);
return toPx(px);
});
const t = toAbsoluteTime(audioTrack.value, timelineTrack.reference, timelineClip.clipIn)
const px = timeline.secondsToPixels(t)
return toPx(px)
})
const width = usePx(() => {
const t = toAbsoluteDuration(audioTrack.value, timelineTrack.reference, timelineClip.duration);
const px = timeline.secondsToPixels(t);
return px;
});
const t = toAbsoluteDuration(audioTrack.value, timelineTrack.reference, timelineClip.duration)
const px = timeline.secondsToPixels(t)
return px
})
const autorepeat = computed(() => timelineClip.autorepeat);
const color = computed(() => timelineClipColor(timelineTrack, timelineClip));
const autorepeat = computed(() => timelineClip.autorepeat)
const color = computed(() => timelineClipColor(timelineTrack, timelineClip))
const isSelected = shallowRef(false);
const isSelected = shallowRef(false)
function selectClip() {
// TODO: make selection manager
isSelected.value = !isSelected.value;
isSelected.value = !isSelected.value
}
// style:
@ -53,33 +54,47 @@ function selectClip() {
// - if not selected, custom colored border
// - if selected, red outline
/* NOTE: the following is "would do anything to avoid hardcoding 4px width limit" */
const selectionRef = useTemplateRef('selection');
const { width: selectionWidth } = useElementBounding(selectionRef);
const outlineSelectedWidth = useCssVar('--timeline-clip-outline-selected-width', selectionRef);
const innerBorderVisible = computed(() => outlineSelectedWidth.value ? selectionWidth.value > 2 * parseInt(outlineSelectedWidth.value, 10) : false);
const selectionRef = useTemplateRef('selection')
const { width: selectionWidth } = useElementBounding(selectionRef)
const outlineSelectedWidth = useCssVar('--timeline-clip-outline-selected-width', selectionRef)
const innerBorderVisible = computed(() => outlineSelectedWidth.value ? selectionWidth.value > 2 * Number.parseInt(outlineSelectedWidth.value, 10) : false)
</script>
<template>
<div @click="selectClip"
class="tw:absolute tw:h-full tw:border tw:rounded-(--timeline-clip-border-radius) tw:overflow-hidden" :style="{
<div
class="tw:absolute tw:h-full tw:border tw:rounded-(--timeline-clip-border-radius) tw:overflow-hidden"
:style="{
left,
width: width.string,
maxWidth: width.string,
borderColor: autorepeat ? 'transparent' : 'var(--timeline-clip-border-color)',
}">
}"
@click="selectClip"
>
<!-- 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" />
<!-- 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="{ 'selection': isSelected, autorepeat }" :style="!isSelected ? { borderColor: color } : null">
<div v-if="!autorepeat && innerBorderVisible" class="tw:absolute tw:size-full tw:max-w-full selection-inner" />
:class="{ selection: isSelected, autorepeat }"
:style="!isSelected ? { borderColor: color } : null"
>
<div
v-if="!autorepeat && innerBorderVisible"
class="tw:absolute tw:size-full tw:max-w-full selection-inner"
/>
</div>
</div>
</template>
<style scoped>
.selection {
border: var(--timeline-clip-outline-selected);

View File

@ -1,21 +1,23 @@
<script setup lang="ts">
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
import { computed } from 'vue';
import BottomLine from './BottomLine.vue';
import AudioWaveform from './AudioWaveform.vue';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import { computed } from 'vue'
import { timelineClipLabel } from '@/lib/Timeline'
import AudioWaveform from './AudioWaveform.vue'
import BottomLine from './BottomLine.vue'
const {
track,
clip,
width,
} = defineProps<{
track: TimelineTrackData,
clip: TimelineClipData,
width: number,
}>();
track: TimelineTrackData
clip: TimelineClipData
width: number
}>()
const label = computed(() => timelineClipLabel(track, clip));
const label = computed(() => timelineClipLabel(track, clip))
</script>
<template>
<!-- waveform -->
<div v-if="clip.audioBuffer !== undefined" class="waveform-wrapper">
@ -32,6 +34,7 @@ const label = computed(() => timelineClipLabel(track, clip));
<!-- clip line -->
<BottomLine />
</template>
<style scoped>
.waveform-wrapper {
position: absolute;

View File

@ -1,120 +1,130 @@
<script setup lang="ts">
import { useWaveform } from '@/audio/AudioWaveform';
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core';
import { shallowRef, useTemplateRef, watchEffect } from 'vue';
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core'
import { shallowRef, useTemplateRef, watchEffect } from 'vue'
import { useWaveform } from '@/audio/AudioWaveform'
const {
buffer,
} = defineProps<{
buffer: AudioBuffer,
}>();
buffer: AudioBuffer
}>()
const canvas = useTemplateRef('canvas');
const canvasWidth = shallowRef(0);
const canvas = useTemplateRef('canvas')
const canvasWidth = shallowRef(0)
// 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.
const waveform = useWaveform(() => buffer, canvasWidth);
const waveform = useWaveform(() => buffer, canvasWidth)
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);
}
let peakHeights = new Uint32Array(0);
let peakHeights = new Uint32Array(0)
const redraw = useThrottleFn((isDone: boolean, peaks: Float32Array) => {
const c = unrefElement(canvas);
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 c = unrefElement(canvas)
if (!c) {
return
}
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) {
// 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 height = Math.round(peakHeight * halfHeight);
peakHeights[x] = height;
const peakHeight = Math.min(1, (peaks[x] ?? 0) * scale)
const height = Math.round(peakHeight * halfHeight)
peakHeights[x] = height
}
ctx.save();
ctx.clearRect(0, 0, c.width, c.height);
ctx.save()
ctx.clearRect(0, 0, c.width, c.height)
ctx.fillStyle = "#ffffffd8";
ctx.strokeStyle = "transparent";
ctx.fillStyle = '#ffffffd8'
ctx.strokeStyle = 'transparent'
// fill first, slanted outline next
for (let x = 0; x < width; x += 1) {
const height = peakHeights[x]!;
const height = peakHeights[x]!
// draw vertically centered
const y = Math.round(halfHeight - height);
ctx.fillRect(x, y, 1, height * 2);
const y = Math.round(halfHeight - height)
ctx.fillRect(x, y, 1, height * 2)
}
// outline
ctx.fillStyle = "transparent";
ctx.strokeStyle = "#00000080";
ctx.lineWidth = 1;
ctx.fillStyle = 'transparent'
ctx.strokeStyle = '#00000080'
ctx.lineWidth = 1
ctx.beginPath();
ctx.beginPath()
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) {
const height = peakHeights[x]!;
const y = sign * height + halfHeight;
ctx.lineTo(x, y);
const height = peakHeights[x]!
const y = sign * height + halfHeight
ctx.lineTo(x, y)
}
}
ctx.stroke();
ctx.stroke()
// middle line
ctx.fillStyle = "#a1a998";
ctx.fillRect(0, Math.round(halfHeight), c.width, 1);
ctx.fillStyle = '#a1a998'
ctx.fillRect(0, Math.round(halfHeight), c.width, 1)
ctx.restore();
}, 0);
ctx.restore()
}, 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(() => {
redraw(waveform.isDone.value, waveform.peaks.value);
}, { flush: 'sync' });
redraw(waveform.isDone.value, waveform.peaks.value)
}, { flush: 'sync' })
</script>
<template>
<canvas ref="canvas" class="tw:size-full">
</canvas>
<canvas ref="canvas" class="tw:size-full" />
</template>
<style scoped></style>

View File

@ -1,20 +1,22 @@
<script setup lang="ts">
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
import { computed } from 'vue';
import BottomLine from './BottomLine.vue';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import { computed } from 'vue'
import { timelineClipLabel } from '@/lib/Timeline'
import BottomLine from './BottomLine.vue'
const {
track,
clip,
width,
} = defineProps<{
track: TimelineTrackData,
clip: TimelineClipData,
width: number,
}>();
track: TimelineTrackData
clip: TimelineClipData
width: number
}>()
const label = computed(() => timelineClipLabel(track, clip));
const label = computed(() => timelineClipLabel(track, clip))
</script>
<template>
<!-- clip label -->
<div class="label-wrapper">
@ -25,6 +27,7 @@ const label = computed(() => timelineClipLabel(track, clip));
<!-- clip line -->
<BottomLine />
</template>
<style scoped>
.label-wrapper {
position: absolute;

View File

@ -1,17 +1,19 @@
<script setup lang="ts">
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
defineProps<{
track: TimelineTrackData,
clip: TimelineClipData,
width: number,
}>();
track: TimelineTrackData
clip: TimelineClipData
width: number
}>()
</script>
<template>
<div class="view">
Yahaha
</div>
</template>
<style scoped>
.view {
position: absolute;

View File

@ -1,20 +1,22 @@
<script setup lang="ts">
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
import Default from './Default.vue';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import Default from './Default.vue'
const {
width,
} = defineProps<{
track: TimelineTrackData,
clip: TimelineClipData,
width: number,
}>();
track: TimelineTrackData
clip: TimelineClipData
width: number
}>()
</script>
<template>
<div class="tw:absolute tw:w-full fade-out-gradient" />
<Default :track :clip :width />
</template>
<style scoped>
.fade-out {
border-style: solid;

View File

@ -1,18 +1,19 @@
<script setup lang="ts">
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
import { computed } from 'vue';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import { computed } from 'vue'
const {
clip,
width,
} = defineProps<{
track: TimelineTrackData,
clip: TimelineClipData,
width: number,
}>();
track: TimelineTrackData
clip: TimelineClipData
width: number
}>()
const lyrics = computed(() => clip.name ?? "");
const lyrics = computed(() => clip.name ?? '')
</script>
<template>
<div class="lyrics-wrapper">
<span class="lyrics-content" :style="{ display: width < 22 ? 'none' : undefined }" :title="lyrics">
@ -20,6 +21,7 @@ const lyrics = computed(() => clip.name ?? "");
</span>
</div>
</template>
<style scoped>
.lyrics-wrapper {
position: absolute;

View File

@ -1,57 +1,64 @@
<script setup lang="ts">
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
// import { toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
import { storeToRefs } from 'pinia';
import Default from './Default.vue';
import { computed, reactive, toRefs } from 'vue';
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useTimelineStore } from '@/store/TimelineStore'
import Default from './Default.vue'
const {
clip,
width,
} = defineProps<{
track: TimelineTrackData,
clip: TimelineClipData,
width: number,
}>();
track: TimelineTrackData
clip: TimelineClipData
width: number
}>()
const { audioTrack } = storeToRefs(useTimelineStore());
const ColorTransitionOut = computed(() => `${(audioTrack.value?.ColorTransitionOut ?? 0) * 100}%`);
const ColorTransitionIn = computed(() => `${100 - (audioTrack.value?.ColorTransitionIn ?? 0) * 100}%`);
const { audioTrack } = storeToRefs(useTimelineStore())
const ColorTransitionOut = computed(() => `${(audioTrack.value?.ColorTransitionOut ?? 0) * 100}%`)
const ColorTransitionIn = computed(() => `${100 - (audioTrack.value?.ColorTransitionIn ?? 0) * 100}%`)
// TODO: shift by BeatsOffset, use new method for computing index into pallete
const colorsPrevNext = computed(() => {
const palette = audioTrack.value?.Palette;
const palette = audioTrack.value?.Palette
if (palette !== undefined && palette.length > 0) {
const nextColorIndex = (clip.clipIn + 1) % palette.length;
const prevColorIndex = (clip.clipIn - 1) % palette.length;
const nextColor = palette[nextColorIndex];
const prevColor = palette[prevColorIndex];
return { prevColor, nextColor };
const nextColorIndex = (clip.clipIn + 1) % palette.length
const prevColorIndex = (clip.clipIn - 1) % palette.length
const nextColor = palette[nextColorIndex]
const prevColor = palette[prevColorIndex]
return { prevColor, nextColor }
}
return { prevColor: clip.color, nextColor: clip.color };
});
return { prevColor: clip.color, nextColor: clip.color }
})
</script>
<template>
<div class="tw:absolute tw:w-full palette-gradient" :style="{
// TODO: this is inaccurate w.r.t. In & Out duration. Also wasteful.
left: `-50%`,
width: `200%`,
'--color-prev': colorsPrevNext.prevColor,
'--color-curr': clip.color,
'--color-next': colorsPrevNext.nextColor,
'--color-transition-out': ColorTransitionOut,
'--color-transition-in': ColorTransitionIn,
}" />
<div class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
:style="{ left: ColorTransitionOut }" />
<div class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
:style="{ left: ColorTransitionIn }" />
<template>
<div
class="tw:absolute tw:w-full palette-gradient" :style="{
// TODO: this is inaccurate w.r.t. In & Out duration. Also wasteful.
'left': `-50%`,
'width': `200%`,
'--color-prev': colorsPrevNext.prevColor,
'--color-curr': clip.color,
'--color-next': colorsPrevNext.nextColor,
'--color-transition-out': ColorTransitionOut,
'--color-transition-in': ColorTransitionIn,
}"
/>
<div
class="tw:absolute tw:top-0 tw:bottom-5.5 tw:border-l tw:border-(--timeline-clip-baseline-color)"
:style="{ left: ColorTransitionOut }"
/>
<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 />
</template>
<style scoped>
.fade-out {
border-style: solid;

View File

@ -3,9 +3,9 @@
* @module components/timeline/clip/impl
*/
export { default as Audio } from "./Audio.vue";
export { default as Default } from "./Default.vue";
export { default as Empty } from "./Empty.vue";
export { default as FadeOut } from "./FadeOut.vue";
export { default as Lyrics } from "./Lyrics.vue";
export { default as Palette } from "./Palette.vue";
export { default as Audio } from './Audio.vue'
export { default as Default } from './Default.vue'
export { default as Empty } from './Empty.vue'
export { default as FadeOut } from './FadeOut.vue'
export { default as Lyrics } from './Lyrics.vue'
export { default as Palette } from './Palette.vue'

View File

@ -1,30 +1,30 @@
import type { TimelineClipData, TimelineTrackData } from "@/lib/Timeline";
import type { Component } from "vue";
import { Audio, Default, FadeOut, Lyrics, Palette } from "./impl";
import type { Component } from 'vue'
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
import { Audio, Default, FadeOut, Lyrics, Palette } from './impl'
export interface ClipContentViewProps {
track: TimelineTrackData;
clip: TimelineClipData;
width: number;
track: TimelineTrackData
clip: TimelineClipData
width: number
}
export type ClipContentViewComponent = Component<ClipContentViewProps>;
export type ClipContentViewComponent = Component<ClipContentViewProps>
export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent {
switch (track.contentViewType) {
case "audio":
return Audio;
case "event":
return Default;
case "fadeout":
return FadeOut;
case "palette":
return Palette;
case "text":
return Lyrics;
case "curve":
return Default;
case 'audio':
return Audio
case 'event':
return Default
case 'fadeout':
return FadeOut
case 'palette':
return Palette
case 'text':
return Lyrics
case 'curve':
return Default
default:
return Default;
return Default
}
}

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { toPx } from '@/lib/vue';
import { toPx } from '@/lib/vue'
const {
left,
@ -7,29 +7,41 @@ const {
label,
position,
} = defineProps<{
left: number;
width: string;
label: string;
position: "top" | "bottom";
}>();
left: number
width: string
label: string
position: 'top' | 'bottom'
}>()
</script>
<template>
<div class="tw:absolute tw:h-full tw:top-0" :class="position" :style="{
left: toPx(left),
width,
}">
<div
class="tw:absolute tw:h-full tw:top-0" :class="position" :style="{
left: toPx(left),
width,
}"
>
<div class="tick-major" />
<div class="tick tick-medium" />
<div v-for="i in 8" class="tick tick-minor" :style="{ left: `${10 * (i < 5 ? i : i + 1)}%` }" />
<div v-for="i in 10" class="tick tick-patch" :style="{ left: `${10 * i + 5}%` }" />
<div
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">
{{ label }}
</span>
</div>
</template>
<style scoped>
.tick-major {
position: absolute;

View File

@ -1,37 +1,49 @@
<script setup lang="ts">
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue';
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks';
import { toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
import { storeToRefs } from 'pinia';
import TickInterval from './TickInterval.vue';
import { storeToRefs } from 'pinia'
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue'
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks'
import { toPx } from '@/lib/vue'
import { useTimelineStore } from '@/store/TimelineStore'
import TickInterval from './TickInterval.vue'
const timeline = useTimelineStore();
const { contentWidthIncludingEmptySpacePx, headerHeight } = storeToRefs(timeline);
const timeline = useTimelineStore()
const { contentWidthIncludingEmptySpacePx, headerHeight } = storeToRefs(timeline)
const allTicks = [
{ ticks: useTimelineTicksSeconds(), position: "top" },
{ ticks: useTimelineTicksBeats(), position: "bottom" },
] as const;
{ ticks: useTimelineTicksSeconds(), position: 'top' },
{ ticks: useTimelineTicksBeats(), position: 'bottom' },
] as const
</script>
<template>
<div class="tw:absolute tw:max-h-full tw:overflow-hidden" style=""
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }">
<!-- header ticks for seconds and beats-->
<div class="tw:absolute tw:size-full" v-for="{ ticks, position } in allTicks">
<TickInterval v-for="tick in ticks.ticks.value" :position :left="ticks.left(tick).value"
:width="ticks.widthPx.value" :label="ticks.label(tick).value" />
<template>
<div
class="tw:absolute tw:max-h-full tw:overflow-hidden"
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }"
>
<!-- header ticks for seconds and beats -->
<div
v-for="{ ticks, position } in allTicks"
: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 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 -->
<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>
</template>
<style scoped></style>

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