Compare commits
1 Commits
dev
...
work/front
| Author | SHA1 | Date |
|---|---|---|
|
|
1c9645e72d |
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/5258#discussioncomment-1979394
|
||||
"css.customData": [
|
||||
".vscode/tailwind.json"
|
||||
],
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{
|
||||
"rule": "style/*",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "format/*",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*-indent",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*-spacing",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*-spaces",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*-order",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*-dangle",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*-newline",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*quotes",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "*semi",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
}
|
||||
],
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
],
|
||||
"workspaceKeybindings.manimPreviewTask.enabled": true,
|
||||
"typescript.format.enable": false,
|
||||
"typescript.tsdk": "./Frontend/node_modules/typescript/lib",
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
{
|
||||
"version": 4.0,
|
||||
"atDirectives": [
|
||||
{
|
||||
"name": "@theme",
|
||||
"description": "Use the `@theme` directive to define your project's custom design tokens, like fonts, colors, and breakpoints.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#theme-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@source",
|
||||
"description": "Use the `@source` directive to explicitly specify source files that aren't picked up by Tailwind's automatic content detection.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#source-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@utility",
|
||||
"description": "Use the `@utility` directive to add custom utilities to your project that work with variants like `hover`, `focus` and `lg`.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#utility-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@variant",
|
||||
"description": "Use the `@variant` directive to apply a Tailwind variant to styles in your CSS.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#variant-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@custom-variant",
|
||||
"description": "Use the `@custom-variant` directive to add a custom variant in your project.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#custom-variant-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@apply",
|
||||
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@reference",
|
||||
"description": "If you want to use `@apply` or `@variant` in the `<style>` block of a Vue or Svelte component, or within CSS modules, you will need to import your theme variables, custom utilities, and custom variants to make those values available in that context.\n\nTo do this without duplicating any CSS in your output, use the `@reference` directive to import your main stylesheet for reference without actually including the styles.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#reference-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@config",
|
||||
"description": "Use the `@config` directive to load a legacy JavaScript-based configuration file.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#config-directive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@plugin",
|
||||
"description": "Use the `@plugin` directive to load a legacy JavaScript-based plugin.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#plugin-directive"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Muzika Gromche — AI Agent Guide
|
||||
|
||||
## Overview
|
||||
|
||||
- **Purpose**: This repository builds a BepInEx plugin (C#) that adds synchronized party music + light effects to Lethal Company, plus a small frontend playground for track metadata and browsing.
|
||||
- **Two main parts**: the core plugin in `MuzikaGromche/` (targets `netstandard2.1`) and the frontend in `Frontend/` (Vite 3 + Vue + Vitest).
|
||||
- **Tools and helpers**: there are some helpful Python scripts in the `playground` directory.
|
||||
|
||||
## What to edit / hotspots
|
||||
|
||||
- `MuzikaGromche/Plugin.cs`: plugin entry and initial configuration registration.
|
||||
- `MuzikaGromche/*.cs` (e.g. `PoweredLights.cs`, `DiscoBallManager.cs`, `SpawnRateManager.cs`, `ScreenFiltersManager.cs`): main game integration and effect logic.
|
||||
- `UnityAssets/` and `MuzikaGromche/bin/...`: Unity assets and compiled outputs used for packaging.
|
||||
- Frontend audio/metadata: `Frontend/src/audio/AudioEngine.ts` and `Frontend/public/*` (track lists and audio bundles).
|
||||
- Sources of audio tracks are edited and mixed outside of this repo, and only final deliveries are checked in here.
|
||||
|
||||
## Build & run (developer workflow)
|
||||
|
||||
- Primary helper: `Justfile` (root) and `MuzikaGromche.just.user` (root, not checked into the repo, so some commands may be unavailable). Common recipes:
|
||||
- `just build` — runs both `build-debug` and `build-release` (calls `dotnet build`).
|
||||
- `just build-release` — `dotnet build --configuration Release`.
|
||||
- `just build-debug` — `dotnet build --configuration Debug`.
|
||||
- `just build-debug run` — build & run the game for testing, most commonly used combination.
|
||||
- `just install-imperium` — installs `dist/MuzikaGromche-Debug.zip` into an r2modman/Imperium profile path (see `Justfile` for `plugin_dir`). Not needed, as it is a post-build step anyway.
|
||||
- `just ogg <track_name>` and `just ogg1 <track_name>` — custom msbuild targets that convert wav→ogg via `dotnet msbuild /t:wav2ogg` (used when adding tracks from outside of this repo).
|
||||
- `just loud <track_name>` — runs a Python script that measures loudness of an audio track in LUFS, useful to calculate volume adjustments for normalization to a consistent desirable level.
|
||||
- `just oggloud1 <track_name>` and `just oggloud <track_name>` — convert and measure loudness in one command invocation (single track, and intro+loop pair of tracks respectively).
|
||||
|
||||
- Frontend: in `Frontend/`:
|
||||
- Use `pnpm` (lockfile `pnpm-lock.yaml` present). Run `pnpm install`.
|
||||
- Dev: `pnpm run dev` (Vite). Build: `pnpm run build`.
|
||||
- Test: `pnpm run test:browser` or `pnpm run coverage`. Uses Vitest with unified config in `vite.config.ts`.
|
||||
|
||||
- Python scripts in `playground`:
|
||||
- Use `uv` tool to run Python code, or corresponding `just` targets if available.
|
||||
|
||||
## Packaging and outputs
|
||||
|
||||
- Plugin target: `netstandard2.1` — compiled DLLs appear under `MuzikaGromche/bin/<Configuration>/netstandard2.1/`.
|
||||
- The repo expects a `dist/` zip for quick install (see `Justfile` `install-imperium` target). Look for `dist/MuzikaGromche-Debug.zip` when installing into a profile.
|
||||
|
||||
## Conventions & patterns
|
||||
|
||||
- BepInEx plugin pattern: follow `Plugin.cs` for how features are registered and how configuration entries are declared.
|
||||
- Prefer small localized changes: keep public APIs stable and avoid broad refactors across many `*.cs` files without tests. The game integration is timing-sensitive.
|
||||
- Manual testing is done by running the game (see `Justfile` `run` target).
|
||||
- Track assets: audio tracks and timings are curated; if you add tracks, use the `just ogg` or `dotnet msbuild` tasks to convert. Run the game once, so that it dumps a new JSON metadata under the frontend `public/` directory to ensure that data stays in sync.
|
||||
|
||||
## Integration points & external dependencies
|
||||
|
||||
- Game modding: integrates with Lethal Company via BepInEx. The repo assumes you understand how to drop plugin DLLs into r2modman profiles (see `Justfile` `plugin_dir`).
|
||||
- External tools: `dotnet` (SDK for building and custom msbuild targets), `just` (task runner) — commands are invoked from repository root; `pnpm`/`node` (frontend) — commands are invoked from `Frontend` directory.
|
||||
|
||||
## Examples to reference
|
||||
|
||||
- Change visual effects: edit `MuzikaGromche/PoweredLights.cs` or `MuzikaGromche/ScreenFiltersManager.cs` and test by running `just build-debug run`.
|
||||
- Add frontend metadata: update `Frontend/src/assets/MuzikaGromcheTracks.json` and run `pnpm run build`.
|
||||
|
||||
## What NOT to assume
|
||||
|
||||
- There are no automated unit tests in the C# mod; it is unreasonably hard to run mod's code outside of Unity runtime, so don't bother with it. Validate changes with local builds and by installing into an r2modman profile.
|
||||
- Packaging and profile paths are user-specific — `Justfile` contains a template `plugin_dir` that uses `$HOME` and `imperium_profile` fields; do not hardcode absolute paths in commits.
|
||||
|
||||
## Where to look next (quick links)
|
||||
|
||||
- `Justfile` and `MuzikaGromche.just.user` — primary developer recipes and packaging helpers.
|
||||
- `MuzikaGromche/` — all plugin source code.
|
||||
- `UnityAssets/` — art/animator resources, treat them as opaque binary blobs.
|
||||
- `Frontend/` — frontend app, `src/audio/AudioEngine.ts` for audio logic.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.cache
|
||||
dist
|
||||
.idea
|
||||
.vite-node
|
||||
ltex*
|
||||
.DS_Store
|
||||
.zed
|
||||
|
||||
# tests & coverage
|
||||
coverage
|
||||
.vitest-reports
|
||||
*.tsbuildinfo
|
||||
|
||||
# exclude static html reporter folder
|
||||
test/browser/html/
|
||||
test/core/html/
|
||||
.vitest-attachments
|
||||
explainFiles.txt
|
||||
.vitest-dump
|
||||
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
# Vite CSS Modules
|
||||
*.module.css.d.ts
|
||||
|
||||
# Project assets
|
||||
/public/MuzikaGromcheAudio/*
|
||||
!/public/MuzikaGromcheAudio/.gitkeep
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Muzika Gromche — Web Player & Editor
|
||||
|
||||
Play your favorite tracks on repeat right in your browser.
|
||||
|
||||
## Project structure
|
||||
|
||||
The look & feel is inspired by a certain popular NLE (Non-Linear video Editor) which incorporates DAW (Digital Audio Workstation) functionality in it.
|
||||
|
||||
- Library page lists all available audio tracks with some basic information presented in cards. There is a search / filter field on top.
|
||||
- Player page features in-depth information about selected audio track, a dedicated timeline widget lets you play, seek, scrub, zoom into timeline tracks & clips, and manupulate them. Each timeline track represents either a segment of the song (intro, loop), or a specific kind of visual effects (such as flickering, fading out & color palette for powered lights, lyrics, drunkness & condensation).
|
||||
|
||||
## Development
|
||||
|
||||
### Adding new tracks
|
||||
|
||||
1. Add track declaration to Plugin.cs and fill in its properties.
|
||||
2. Launch the game once, so that it generates a new JSON dump (in the Lethal Company save files directory) of all tracks.
|
||||
3. Replace `src/assets/MuzikaGromcheTracks.json` with the new JSON dump.
|
||||
4. Discard lines with IP address and username or replace them with generic ones.
|
||||
|
||||
### Run & test
|
||||
|
||||
First time setup:
|
||||
- copy audio files from the `/Assets/` directory located at repository's root to `Frontend/public/MuzikaGromcheAudio/` directory.
|
||||
|
||||
Muzika Gromche Web Player & Editor is built with Vue 3 + TypeScript + Vite.
|
||||
|
||||
```sh
|
||||
pnpm run dev
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Use scp, rsync or any other tool to upload content of `dist/` to root@ratijas.me `/var/www/html/muzika-gromche/`.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu({
|
||||
lessOpinionated: true,
|
||||
ignores: [
|
||||
'src/assets/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,
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/icon-32.png" sizes="32x32" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Muzika Gromche — The ultimate Jester party music mod</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "muzika-gromche-frontend",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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",
|
||||
"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.18",
|
||||
"@unhead/vue": "^2.1.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.7.3",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.9",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"@vitest/eslint-plugin": "^1.6.6",
|
||||
"@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.2",
|
||||
"eslint-plugin-format": "^1.3.1",
|
||||
"eslint-plugin-vue": "~10.6.2",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"vite-css-modules": "^1.12.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.5",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-browser-vue": "^2.0.2",
|
||||
"vue-tsc": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
catalogMode: manual
|
||||
|
||||
shellEmulator: true
|
||||
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
- semver@6.3.1
|
||||
- undici-types@6.21.0
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
|
|
@ -0,0 +1 @@
|
|||
Copy /Assets/ to this directory.
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
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>
|
||||
<div class="tw:h-full tw:flex tw:flex-col">
|
||||
<GlobalHeader class="tw:flex-none" />
|
||||
<main class="tw:flex-auto tw:overflow-hidden">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
After Width: | Height: | Size: 124 B |
|
After Width: | Height: | Size: 426 B |
|
|
@ -0,0 +1,554 @@
|
|||
/*
|
||||
AudioEngine.ts
|
||||
A small singleton wrapper around the WebAudio AudioContext that:
|
||||
- lazily creates the AudioContext
|
||||
- provides fetch/decode + simple caching
|
||||
- schedules intro and loop buffers to play seamlessly
|
||||
- exposes play/pause/stop and a volume control via a master GainNode
|
||||
- provides a short fade-in/out GainNode to avoid clicks (few ms)
|
||||
- exposes getPosition() to read current playback time relative to intro start
|
||||
*/
|
||||
|
||||
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 {
|
||||
|
||||
tryOnScopeDispose,
|
||||
useRafFn,
|
||||
useThrottleFn,
|
||||
watchImmediate,
|
||||
} from '@vueuse/core'
|
||||
import {
|
||||
|
||||
shallowRef,
|
||||
toValue,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { useWrapTime, wrapTimeFn } from '@/lib/AudioTrack'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playing intro + loop buffers at given position.
|
||||
*
|
||||
* @returns Handle with introStartTime and stop() method.
|
||||
*/
|
||||
function playAudioTrackBuffers(
|
||||
audioCtx: AudioContext,
|
||||
destinationNode: AudioNode,
|
||||
audioTrack: AudioTrack,
|
||||
/**
|
||||
* Position in seconds from the start of the intro
|
||||
*/
|
||||
startPosition: Seconds = 0,
|
||||
): AudioTrackBuffersHandle {
|
||||
const now = audioCtx.currentTime
|
||||
|
||||
const introBuffer = audioTrack.loadedIntro!
|
||||
const loopBuffer = audioTrack.loadedLoop!
|
||||
|
||||
const introDuration = introBuffer.duration
|
||||
const loopDuration = loopBuffer.duration
|
||||
|
||||
const wrapper = wrapTimeFn(audioTrack)
|
||||
startPosition = wrapper(startPosition)
|
||||
|
||||
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 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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function stop(when?: Seconds) {
|
||||
try {
|
||||
currentIntro?.stop(when)
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
currentLoop?.stop(when)
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
currentIntro = null
|
||||
currentLoop = null
|
||||
}
|
||||
|
||||
return { introStartTime, stop }
|
||||
}
|
||||
|
||||
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
|
||||
playerResult: Omit<T, 'stop'>
|
||||
}
|
||||
|
||||
/**
|
||||
* 25 ms for fade-in/fade-out
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @returns Handle with introStartTime and stop() method.
|
||||
*/
|
||||
function playWithFadeInOut<T extends PlayerHandle>(
|
||||
audioCtx: AudioContext,
|
||||
destinationNode: AudioNode,
|
||||
player: (destinationNode: AudioNode) => T,
|
||||
/**
|
||||
* Duration of fade in/out in seconds. Fade out extends past the stop() call.
|
||||
*/
|
||||
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
|
||||
): PlayWithFadeInOut<T> {
|
||||
const GAIN_MIN = 0.0001
|
||||
const GAIN_MAX = 1.0
|
||||
|
||||
const fadeGain = audioCtx.createGain()
|
||||
fadeGain.connect(destinationNode)
|
||||
fadeGain.gain.value = GAIN_MIN
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
playerHandle.stop(fadeEnd)
|
||||
}
|
||||
|
||||
return { playerResult: playerHandle, stop }
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties relates to the state of playback.
|
||||
*/
|
||||
export interface PlaybackState {
|
||||
/**
|
||||
* Readonly reference to whether audio is currently playing.
|
||||
*/
|
||||
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>>
|
||||
/**
|
||||
* Returns current playback position in seconds based on AudioContext time.
|
||||
*
|
||||
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
|
||||
*/
|
||||
getCurrentPosition: () => Seconds
|
||||
}
|
||||
|
||||
export interface StopOptions {
|
||||
/**
|
||||
* If true, update remembered playback position to current position, otherwise revert to last remembered one.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
rememberPosition?: boolean
|
||||
}
|
||||
|
||||
export interface SeekOptions {
|
||||
/**
|
||||
* If scrub is requested, plays a short sample at that position.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
scrub?: boolean
|
||||
// TODO: optionally keep playing after seeking?
|
||||
}
|
||||
|
||||
/**
|
||||
* Player controls and properties relates to the state of playback.
|
||||
*/
|
||||
export interface PlayerControls {
|
||||
/**
|
||||
* Start playing audio buffers from the last remembered position.
|
||||
*/
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Properties relates to the state of playback.
|
||||
*/
|
||||
readonly playback: PlaybackState
|
||||
}
|
||||
|
||||
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
|
||||
}
|
||||
|
||||
function reusableAudioBuffersTrackPlayer(
|
||||
audioCtx: AudioContext,
|
||||
destinationNode: AudioNode,
|
||||
audioTrack: AudioTrack,
|
||||
): ReusableAudioBuffersTrackPlayer {
|
||||
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null
|
||||
const isPlaying = shallowRef(false)
|
||||
const wrapper = wrapTimeFn(audioTrack)
|
||||
const startPosition = useWrapTime(audioTrack, 0)
|
||||
|
||||
function play() {
|
||||
if (currentHandle) {
|
||||
return
|
||||
}
|
||||
currentHandle = playWithFadeInOut(
|
||||
audioCtx,
|
||||
destinationNode,
|
||||
destinationNode =>
|
||||
playAudioTrackBuffers(
|
||||
audioCtx,
|
||||
destinationNode,
|
||||
audioTrack,
|
||||
startPosition.value,
|
||||
),
|
||||
)
|
||||
isPlaying.value = true
|
||||
}
|
||||
|
||||
function stop(options?: { rememberPosition?: boolean }) {
|
||||
const {
|
||||
rememberPosition = false,
|
||||
} = options ?? {}
|
||||
|
||||
if (currentHandle) {
|
||||
isPlaying.value = false
|
||||
|
||||
if (rememberPosition) {
|
||||
startPosition.value = getCurrentPosition()
|
||||
}
|
||||
|
||||
// stop and discard current handle
|
||||
currentHandle.stop()
|
||||
currentHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
// Scrub is subject to debouncing/throttling, so it doesn't start
|
||||
// playing samples too often before previous ones could stop.
|
||||
const doThrottledScrub = useThrottleFn(() => {
|
||||
// play a short sample at the seeked position
|
||||
const scrubHandle = playWithFadeInOut(
|
||||
audioCtx,
|
||||
destinationNode,
|
||||
destinationNode =>
|
||||
playAudioTrackBuffers(
|
||||
audioCtx,
|
||||
destinationNode,
|
||||
audioTrack,
|
||||
startPosition.value,
|
||||
),
|
||||
0.01, // short fade of 10 ms
|
||||
)
|
||||
setTimeout(() => {
|
||||
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
|
||||
}
|
||||
|
||||
const elapsed = audioCtx.currentTime
|
||||
- currentHandle.playerResult.introStartTime
|
||||
|
||||
return wrapper(elapsed)
|
||||
}
|
||||
|
||||
return {
|
||||
play,
|
||||
stop,
|
||||
seek,
|
||||
playback: {
|
||||
isPlaying,
|
||||
startPosition,
|
||||
getCurrentPosition,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface LivePlaybackPositionOptions extends ConfigurableWindow {
|
||||
}
|
||||
|
||||
interface LivePlaybackPositionReturn {
|
||||
stop: () => void
|
||||
position: Readonly<Ref<Seconds>>
|
||||
}
|
||||
|
||||
export function useLivePlaybackPosition(
|
||||
playback: MaybeRefOrGetter<PlaybackState | null>,
|
||||
options?: LivePlaybackPositionOptions,
|
||||
): LivePlaybackPositionReturn {
|
||||
const cleanups: (() => void)[] = []
|
||||
const cleanup = () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
cleanups.length = 0
|
||||
}
|
||||
|
||||
const getPosition = () => {
|
||||
return toValue(playback)?.getCurrentPosition() ?? 0
|
||||
}
|
||||
|
||||
const position = shallowRef<Seconds>(getPosition())
|
||||
|
||||
const updatePosition = () => {
|
||||
position.value = getPosition()
|
||||
}
|
||||
|
||||
const raf = useRafFn(() => {
|
||||
updatePosition()
|
||||
}, {
|
||||
...options,
|
||||
immediate: false,
|
||||
once: false,
|
||||
})
|
||||
|
||||
const stopWatch = watchImmediate(() => [
|
||||
toValue(playback),
|
||||
], ([playback]) => {
|
||||
cleanup()
|
||||
|
||||
updatePosition()
|
||||
|
||||
if (!playback) {
|
||||
return
|
||||
}
|
||||
|
||||
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
|
||||
if (isPlaying) {
|
||||
raf.resume()
|
||||
}
|
||||
else {
|
||||
raf.pause()
|
||||
updatePosition()
|
||||
}
|
||||
}))
|
||||
|
||||
cleanups.push(watch(playback.startPosition, () => {
|
||||
raf.pause()
|
||||
updatePosition()
|
||||
if (playback.isPlaying.value) {
|
||||
raf.resume()
|
||||
}
|
||||
}))
|
||||
|
||||
cleanups.push(() => raf.pause())
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
stopWatch()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
tryOnScopeDispose(cleanup)
|
||||
|
||||
return { stop, position }
|
||||
}
|
||||
|
||||
export function togglePlayStop(
|
||||
player: PlayerControls | null,
|
||||
options?: StopOptions,
|
||||
) {
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
if (player.playback.isPlaying.value) {
|
||||
player.stop(options)
|
||||
}
|
||||
else {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
class AudioEngine {
|
||||
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>()
|
||||
|
||||
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
|
||||
|
||||
init() {
|
||||
if (this.audioCtx) {
|
||||
return
|
||||
}
|
||||
this.audioCtx
|
||||
= new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
|
||||
this.masterGain = this.audioCtx.createGain()
|
||||
|
||||
// routing: sources -> fadeGain -> masterGain -> destination
|
||||
this.masterGain.connect(this.audioCtx.destination)
|
||||
// default full volume
|
||||
this.masterGain.gain.value = 1
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
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}`)
|
||||
}
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
initPlayer(
|
||||
audioTrack: AudioTrack,
|
||||
): PlayerControls | null {
|
||||
this.init()
|
||||
if (!this.audioCtx || !this.masterGain) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.stopPlayer()
|
||||
|
||||
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) {
|
||||
return null
|
||||
}
|
||||
|
||||
const player = reusableAudioBuffersTrackPlayer(
|
||||
this.audioCtx,
|
||||
this.masterGain,
|
||||
audioTrack,
|
||||
)
|
||||
this._player.value = player
|
||||
return player
|
||||
}
|
||||
|
||||
private stopPlayer() {
|
||||
if (this._player.value) {
|
||||
this._player.value.stop()
|
||||
this._player.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const audioEngine = new AudioEngine()
|
||||
export default audioEngine
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
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
|
||||
}
|
||||
|
||||
interface WaveformComputation {
|
||||
readonly isDone: Readonly<Ref<boolean>>
|
||||
readonly peaks: Readonly<Ref<Float32Array>>
|
||||
/** Start or continue asynchronous computation. */
|
||||
run: () => void
|
||||
/** Stops any ongoing asynchronous computation. */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
|
||||
() => new Map(),
|
||||
)
|
||||
|
||||
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 cleanup = () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
cleanups.length = 0
|
||||
}
|
||||
|
||||
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation)
|
||||
|
||||
const stopWatch = watchImmediate(
|
||||
() =>
|
||||
[
|
||||
toValue(buffer),
|
||||
toValue(width),
|
||||
] as const,
|
||||
([b, w]) => {
|
||||
cleanup()
|
||||
|
||||
const map = waveformsCache.getOrNew(b)
|
||||
|
||||
if (w < WAVEFORM_MIN_WIDTH) {
|
||||
compRef.value = emptyComputation
|
||||
return
|
||||
}
|
||||
|
||||
let comp = map.get(w)
|
||||
if (!comp) {
|
||||
comp = useWaveformComputation(b, w)
|
||||
map.set(w, comp)
|
||||
}
|
||||
compRef.value = comp
|
||||
comp.run()
|
||||
cleanups.push(() => {
|
||||
compRef.value = emptyComputation
|
||||
comp.stop()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const stop = () => {
|
||||
stopWatch()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
tryOnScopeDispose(stop)
|
||||
|
||||
return {
|
||||
isDone: computed(() => compRef.value.isDone.value),
|
||||
peaks: computed(() => compRef.value.peaks.value),
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// How many pixels of `width` have been processed so far
|
||||
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 isDone = shallowRef(false)
|
||||
const peaks = shallowRef(waveform)
|
||||
|
||||
const nChannels = buffer.numberOfChannels
|
||||
|
||||
const samplesPerPx = buffer.length / width
|
||||
const blocksPerChannel: Float32Array<ArrayBuffer>[] = []
|
||||
for (let channel = 0; channel < nChannels; channel++) {
|
||||
blocksPerChannel[channel] = new Float32Array(Math.ceil(samplesPerPx))
|
||||
}
|
||||
|
||||
const areWeDoneYet = () => progress >= width
|
||||
|
||||
function stepBlock() {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
while (!areWeDoneYet()) {
|
||||
stepBlock()
|
||||
if (performance.now() - start >= 10 || progress - progressStart > 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (areWeDoneYet()) {
|
||||
isDone.value = true
|
||||
timeoutID = undefined
|
||||
}
|
||||
else {
|
||||
timeoutID = setTimeout(stepBatchOfBlocks, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDone,
|
||||
peaks,
|
||||
run() {
|
||||
users += 1
|
||||
|
||||
if (timeoutID === undefined && users === 1) {
|
||||
timeoutID = setTimeout(stepBatchOfBlocks, 0)
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
users -= 1
|
||||
|
||||
if (!timeoutID === undefined && users === 0) {
|
||||
window.clearTimeout(timeoutID)
|
||||
timeoutID = undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
function compressBlock(channels: Float32Array[], blockSize: number): number {
|
||||
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]!))
|
||||
}
|
||||
}
|
||||
return peak
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
} = defineProps<{
|
||||
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>
|
||||
<p class="tw:p-4">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
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–2026'
|
||||
const author = 'Ratijas'
|
||||
const authorLink = 'https://ratijas.me'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer>
|
||||
<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);"
|
||||
>
|
||||
<span>
|
||||
<span>
|
||||
<a :href="productLink" target="_blank" rel="nofollow">{{ product }}</a>
|
||||
</span>
|
||||
<template v-if="version">
|
||||
<span>
|
||||
v{{ version }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<div class="separator tw:max-sm:hidden" />
|
||||
<span>
|
||||
Copyright © {{ year }}
|
||||
</span>
|
||||
<div class="separator tw:max-sm:hidden" />
|
||||
<span>
|
||||
Made by <a :href="authorLink" target="_blank" rel="nofollow">{{ author }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.separator {
|
||||
align-self: stretch;
|
||||
max-height: 1em;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
border-left: 1.5px solid;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
import { TransitionPresets, useTransition, watchDebounced } from '@vueuse/core'
|
||||
import { computed, shallowRef, useId } from 'vue'
|
||||
import ScreenTransition from './ScreenTransition.vue'
|
||||
|
||||
const {
|
||||
visible,
|
||||
message = 'Loading…',
|
||||
progress = undefined,
|
||||
} = defineProps<{
|
||||
visible: boolean
|
||||
message?: string
|
||||
// loading progress, range 0..1
|
||||
progress?: number | undefined
|
||||
}>()
|
||||
|
||||
// CSS transition on width does not work in Firefox
|
||||
const zeroProgress = computed(() => progress ?? 0)
|
||||
const easedProgress = useTransition(zeroProgress, {
|
||||
duration: 400,
|
||||
easing: TransitionPresets.easeInOutQuad,
|
||||
})
|
||||
|
||||
const progressId = useId()
|
||||
// Let the progress animation finish before cutting it off of updates
|
||||
const actuallyVisible = shallowRef(visible)
|
||||
watchDebounced(() => 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">
|
||||
<label :for="progressId">
|
||||
{{ message }}
|
||||
</label>
|
||||
<progress :id="progressId" class="progress" max="1" :value="easedProgress">
|
||||
<template v-if="progress !== undefined">
|
||||
{{ Math.floor(progress * 100) }} %
|
||||
</template>
|
||||
</progress>
|
||||
</div>
|
||||
</ScreenTransition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.progress {
|
||||
appearance: none;
|
||||
background-color: #1a1a1a;
|
||||
height: 24px;
|
||||
padding: 5px;
|
||||
width: 350px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 5px #000 inset, 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.progress::-webkit-progress-bar {
|
||||
appearance: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* for some reason combining these two selector via comma breaks Chromium */
|
||||
.progress::-webkit-progress-value {
|
||||
appearance: none;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background-color: #444;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, .5) inset;
|
||||
|
||||
background-image: -webkit-linear-gradient(135deg,
|
||||
transparent 33%, rgba(0, 0, 0, .1) 33%,
|
||||
rgba(0, 0, 0, .1) 66%, transparent 66%);
|
||||
background-size: 35px 20px;
|
||||
}
|
||||
|
||||
.progress::-moz-progress-bar {
|
||||
appearance: none;
|
||||
background-color: #444;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, .5) inset;
|
||||
|
||||
background-image: repeating-linear-gradient(135deg,
|
||||
transparent 0%, transparent 33%,
|
||||
rgba(0, 0, 0, .1) 33%, rgba(0, 0, 0, .1) 66%);
|
||||
background-size: 35px 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@property --screen-transition-duration {
|
||||
syntax: "<time>";
|
||||
inherits: false;
|
||||
initial-value: 0.5s;
|
||||
}
|
||||
|
||||
/* this placeholder is needed, because Vue.js can not figure out transition duration based on nested styles */
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity var(--screen-transition-duration) linear;
|
||||
}
|
||||
|
||||
.v-enter-active>div,
|
||||
.v-leave-active>div {
|
||||
transition: transform var(--screen-transition-duration) ease-out;
|
||||
}
|
||||
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.v-leave-to>div {
|
||||
transform: scale(130%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<script setup lang="ts">
|
||||
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…',
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
const model = defineModel({ type: String, required: true })
|
||||
|
||||
const clearDisabled = computed(() => model.value === '')
|
||||
function clear() {
|
||||
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 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;
|
||||
transition: color 150ms linear;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
color: #767676;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts" setup>
|
||||
import { useTimelineStore } from '@/store/TimelineStore'
|
||||
import { useTrackStore } from '@/store/TrackStore'
|
||||
|
||||
const trackStore = useTrackStore()
|
||||
const timeline = useTimelineStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scene-wrapper">
|
||||
<div class="scene">
|
||||
<h3>
|
||||
Preview Scene
|
||||
</h3>
|
||||
<p>
|
||||
Track store status: {{ trackStore.status }}
|
||||
</p>
|
||||
<p>
|
||||
Current track: {{ trackStore.currentAudioTrackName }}
|
||||
</p>
|
||||
<p>
|
||||
Track status: {{ trackStore.audioTrackStatus }}
|
||||
</p>
|
||||
<p>
|
||||
Track progress: {{ trackStore.audioTrackProgress }}
|
||||
</p>
|
||||
<p>
|
||||
Playback status: {{ timeline.isPlaying }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scene-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene {
|
||||
width: 480px;
|
||||
height: 160px;
|
||||
border: 5px solid #ccc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
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">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name:</td>
|
||||
<td>
|
||||
{{ track.Name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Language:</td>
|
||||
<td>
|
||||
<select v-if="edit" v-model="track.Language">
|
||||
<option v-for="language in LANGUAGES" :key="language" :value="language">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-else>
|
||||
{{ track.Language }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Is explicit:</td>
|
||||
<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>
|
||||
<td>Intro:</td>
|
||||
<td><Timestamp :seconds="track.WindUpTimer" :beats="windUpBeats" /><span style="vertical-align: bottom;"> = 4 * {{ (windUpBeats / 4).toFixed(2) }} bars</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Loop:</td>
|
||||
<td><Timestamp :seconds="track.loadedLoop!.duration" :beats="track.Beats" /><span style="vertical-align: bottom;"> = 4 * {{ track.Beats / 4 }} bars</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.info-table td {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.info-table tr td:first-child {
|
||||
font-weight: bold;
|
||||
text-align: end;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table tr td:nth-child(2) {
|
||||
text-align: start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<script setup lang="ts">
|
||||
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 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" />
|
||||
Inspector
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
>
|
||||
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" :audio-track />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollablePanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
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))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="view" :control="control" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { Controls } from '../controls'
|
||||
import Control from './Control.vue'
|
||||
|
||||
const {
|
||||
controls,
|
||||
} = defineProps<{
|
||||
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);"
|
||||
>
|
||||
<Control v-for="control in controls" :key="control.key" :control />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import type { BaseNamedControl } from '@/components/inspector/controls'
|
||||
|
||||
const {
|
||||
control,
|
||||
id,
|
||||
} = defineProps<{
|
||||
control: BaseNamedControl
|
||||
/**
|
||||
* Input ID for an associated label.
|
||||
*/
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
// TODO: reset function
|
||||
function reset(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- label -->
|
||||
<label
|
||||
:for="id"
|
||||
class="tw:text-right control-label"
|
||||
:class="{ 'control-label__disabled': control.disabled }"
|
||||
@dblclick="reset"
|
||||
>
|
||||
{{ control.name }}
|
||||
</label>
|
||||
|
||||
<!-- control -->
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { ButtonControl } from '@/components/inspector/controls'
|
||||
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
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"
|
||||
: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>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import type { CheckboxControl } from '@/components/inspector/controls'
|
||||
import { useId } from 'vue'
|
||||
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: CheckboxControl
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 }}
|
||||
</span>
|
||||
</label>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropDownControl } from '@/components/inspector/controls'
|
||||
import { useId } from 'vue'
|
||||
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: DropDownControl
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { HrControl } from '@/components/inspector/controls'
|
||||
|
||||
defineProps<{
|
||||
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);">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { BaseControl } from '@/components/inspector/controls'
|
||||
|
||||
defineProps<{
|
||||
control: BaseControl
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:col-span-full">
|
||||
Not Implemented: {{ control.kind }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import type { NumberControl } from '@/components/inspector/controls'
|
||||
import { useId } from 'vue'
|
||||
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: NumberControl
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :id :control>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :id :control>
|
||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
|
||||
<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>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import type { TextControl } from '@/components/inspector/controls'
|
||||
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: TextControl
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { TextControl } from '@/components/inspector/controls'
|
||||
import BaseNamedControlView from './BaseNamedControlView.vue'
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
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"
|
||||
:class="{ 'tw:font-mono': control.monospace }"
|
||||
>
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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> = {
|
||||
hr: HrControlView,
|
||||
text: TextControlView,
|
||||
textarea: TextAreaControlView,
|
||||
number: NumberControlView,
|
||||
range: RangeControlView,
|
||||
checkbox: CheckboxControlView,
|
||||
dropdown: DropDownControlView,
|
||||
button: ButtonControlView,
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `control.kind` to the component that renders it.
|
||||
*
|
||||
* @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
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import type { Component, Ref } from 'vue'
|
||||
|
||||
export interface BaseControl {
|
||||
/**
|
||||
* Discriminator for different types of controls.
|
||||
*/
|
||||
kind: string
|
||||
/**
|
||||
* Unique key of the control.
|
||||
*/
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface HrControl extends BaseControl {
|
||||
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
|
||||
/**
|
||||
* An Icon component to display inline with a label.
|
||||
*/
|
||||
icon?: string | Component
|
||||
/**
|
||||
* Whether the control is disabled as a whole. Dims the label and implies readonly.
|
||||
*/
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Whether the value should be allowed to change.
|
||||
*/
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export interface BaseTextControl extends BaseNamedControl {
|
||||
ref: Ref<string>
|
||||
/** Whether to use monospace font. Defaults to false. */
|
||||
monospace?: boolean
|
||||
}
|
||||
|
||||
export interface TextControl extends BaseTextControl {
|
||||
kind: 'text'
|
||||
}
|
||||
|
||||
export interface TextAreaControl extends BaseTextControl {
|
||||
kind: 'textarea'
|
||||
}
|
||||
|
||||
export interface BaseNumberControl extends BaseNamedControl {
|
||||
min: number
|
||||
max: number
|
||||
default: number
|
||||
ref: Ref<number>
|
||||
}
|
||||
|
||||
/** A range slider accompanied by an input field. */
|
||||
export interface RangeControl extends BaseNumberControl {
|
||||
kind: 'range'
|
||||
}
|
||||
|
||||
/** A text input field for a number. */
|
||||
export interface NumberControl extends BaseNumberControl {
|
||||
kind: 'number'
|
||||
}
|
||||
|
||||
export interface CheckboxControl extends BaseNamedControl {
|
||||
kind: 'checkbox'
|
||||
/** Optional additional label for the checkbox input */
|
||||
label?: string
|
||||
ref: Ref<boolean>
|
||||
}
|
||||
|
||||
export interface DropDownControl extends BaseNamedControl {
|
||||
kind: 'dropdown'
|
||||
options: readonly string[]
|
||||
ref: Ref<string>
|
||||
}
|
||||
|
||||
export interface ButtonControl extends BaseNamedControl {
|
||||
kind: 'button'
|
||||
/** Unlike control's name label, this property is text on the button itself. */
|
||||
text: string
|
||||
/** Called when the button is pressed. */
|
||||
action: () => void
|
||||
}
|
||||
|
||||
export type Control
|
||||
= | HrControl
|
||||
| TextControl
|
||||
| TextAreaControl
|
||||
| RangeControl
|
||||
| NumberControl
|
||||
| CheckboxControl
|
||||
| DropDownControl
|
||||
| ButtonControl
|
||||
|
||||
export type Controls = Control[]
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const easing = ref(audioTrack.ColorTransitionEasing)
|
||||
const season = computed<string>({
|
||||
get() {
|
||||
return audioTrack.Season ?? ''
|
||||
},
|
||||
set(value) {
|
||||
audioTrack.Season = value
|
||||
},
|
||||
})
|
||||
const gameOverText = computed<string>({
|
||||
get() {
|
||||
return audioTrack.GameOverText ?? ''
|
||||
},
|
||||
set(value) {
|
||||
audioTrack.GameOverText = value
|
||||
},
|
||||
})
|
||||
|
||||
const controls = computed<Controls>(() => [
|
||||
{
|
||||
kind: 'text',
|
||||
key: 'Name',
|
||||
name: 'Name',
|
||||
ref: ref(audioTrack.Name),
|
||||
readonly: true,
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
key: 'Artist',
|
||||
name: 'Artist',
|
||||
ref: ref(audioTrack.Artist),
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
key: 'Song',
|
||||
name: 'Song',
|
||||
ref: ref(audioTrack.Song),
|
||||
},
|
||||
{
|
||||
kind: 'hr',
|
||||
key: 'audioTrack.hr.1',
|
||||
},
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'IsExplicit',
|
||||
name: 'Is Explicit',
|
||||
icon: Explicit,
|
||||
ref: ref(audioTrack.IsExplicit),
|
||||
label: 'Explicit',
|
||||
},
|
||||
{
|
||||
kind: 'dropdown',
|
||||
key: 'Language',
|
||||
name: 'Language',
|
||||
ref: ref(audioTrack.Language),
|
||||
options: LANGUAGES,
|
||||
},
|
||||
{
|
||||
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',
|
||||
disabled: true,
|
||||
min: 0,
|
||||
max: 128,
|
||||
default: 0,
|
||||
ref: ref(audioTrack.LoopOffset),
|
||||
},
|
||||
{
|
||||
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',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
default: 2,
|
||||
ref: ref(audioTrack.FadeOutDuration),
|
||||
disabled: 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: gameOverText,
|
||||
monospace: true,
|
||||
},
|
||||
])
|
||||
</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>
|
||||
<ControlsView :controls />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<script setup lang="ts">
|
||||
import { shallowRef, useTemplateRef } from 'vue'
|
||||
|
||||
// Any card with a subtle outline and hover effect
|
||||
const {
|
||||
hoverEnabled = true,
|
||||
playheadEnabled = true,
|
||||
selected = false,
|
||||
} = defineProps<{
|
||||
hoverEnabled?: boolean
|
||||
playheadEnabled?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(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>(Number.NaN)
|
||||
const playheadEl = useTemplateRef('playheadEl')
|
||||
const card = useTemplateRef('card')
|
||||
|
||||
// Simply tracks pointer enter/leave
|
||||
const isHovered = shallowRef(false)
|
||||
// flag to detect a recent touch tap so the following click doesn't also emit 'select'
|
||||
const lastTapWasTouch = shallowRef(false)
|
||||
// show playhead on touch while pressing
|
||||
const isTouchActive = shallowRef(false)
|
||||
// Once dragging starts, pointer should no longer cause click on release/up
|
||||
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
|
||||
if (!hoverEnabled || !playheadEnabled || !target) {
|
||||
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
|
||||
|
||||
if (playheadEl.value) {
|
||||
// position the 1px playhead using percentage so it adapts to responsive widths
|
||||
playheadEl.value.style.left = `${pos * 100}%`
|
||||
}
|
||||
// emit normalized position for parent components to react (e.g. preview color)
|
||||
emit('playhead', pos)
|
||||
return true
|
||||
}
|
||||
|
||||
function onPointerEnter(_event: PointerEvent) {
|
||||
if (hoverEnabled) {
|
||||
isHovered.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerLeave(_event: PointerEvent) {
|
||||
if (hoverEnabled) {
|
||||
isHovered.value = false
|
||||
}
|
||||
isTouchActive.value = false
|
||||
isTouchDragging.value = false
|
||||
playheadPosition01.value = Number.NaN
|
||||
if (playheadEl.value) {
|
||||
playheadEl.value.style.left = ''
|
||||
}
|
||||
emit('playhead', Number.NaN)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return
|
||||
}
|
||||
if (updatePlayhead(event)) {
|
||||
isTouchActive.value = true
|
||||
}
|
||||
if (card.value) {
|
||||
card.value.setPointerCapture(event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
// Treat a single touch tap as activation
|
||||
if (event.pointerType === 'touch') {
|
||||
if (!isTouchDragging.value) {
|
||||
emit('activate')
|
||||
}
|
||||
lastTapWasTouch.value = true
|
||||
// keep the flag true briefly so the subsequent click handler can suppress 'select'
|
||||
setTimeout(() => {
|
||||
lastTapWasTouch.value = false
|
||||
}, 50)
|
||||
// clear touch-active playhead state
|
||||
isTouchActive.value = false
|
||||
isTouchDragging.value = false
|
||||
onPointerLeave(event)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
updatePlayhead(event)
|
||||
if (event.pointerType === 'touch') {
|
||||
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
|
||||
}
|
||||
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"
|
||||
@pointerup="onPointerUp" @pointermove="onPointerMove" @click="onClick" @dblclick="onDblClick"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
color: var(--inactive-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.card.hover-enabled:hover,
|
||||
.card.selected {
|
||||
color: var(--active-text-color);
|
||||
}
|
||||
|
||||
.card.hover-enabled:hover {
|
||||
outline: 2px solid var(--card-outline-color);
|
||||
}
|
||||
|
||||
.card.selected,
|
||||
.card.selected:hover {
|
||||
outline: 2px solid var(--card-outline-selected-color);
|
||||
}
|
||||
|
||||
.playhead-container {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card.playhead-enabled.playhead-active .playhead-container {
|
||||
display: unset;
|
||||
}
|
||||
|
||||
.playhead {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
/* center the 1px line on the pointer */
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--timeline-playhead-color);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<hr class="card-separator">
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.card-separator {
|
||||
@apply my-0.5;
|
||||
min-height: var(--card-separator-width);
|
||||
color: var(--card-separator-color);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
color: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<script setup lang="ts">
|
||||
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 selectedTrackName = ref<string | null>(null)
|
||||
|
||||
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
|
||||
for (let j = 0; j < haystack.length && i < needle.length; j++) {
|
||||
if (haystack[j] === needle[i]) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i === needle.length
|
||||
}
|
||||
|
||||
const trackMatches = (track: AudioTrack): boolean => {
|
||||
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)
|
||||
|
||||
// gather candidate fields to search (lowercased)
|
||||
const fields: string[] = []
|
||||
const pushField = (v?: string) => {
|
||||
if (typeof v === 'string' && v.length > 0) {
|
||||
fields.push(v.toLowerCase())
|
||||
}
|
||||
}
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
const filteredGroupedSortedTracks = computed(() => {
|
||||
if (filterText.value === '') {
|
||||
return trackStore.groupedSortedTracks
|
||||
}
|
||||
else {
|
||||
return trackStore.groupedSortedTracks
|
||||
.map(([language, tracks]) => {
|
||||
tracks = tracks.filter(trackMatches)
|
||||
return [language, tracks] as const
|
||||
})
|
||||
// remove empty languages
|
||||
.filter(([_language, tracks]) => tracks.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;"
|
||||
>
|
||||
<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;"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<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" :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 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<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">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-header__separator {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
color: transparent;
|
||||
background-image: radial-gradient(closest-side,
|
||||
var(--timeline-track-border-color) 25%,
|
||||
transparent 75%);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Slider's orientation */
|
||||
export type Orientation = 'horizontal' | 'vertical'
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<script setup lang="ts">
|
||||
import type { Orientation } from './Slider'
|
||||
import { computed, useAttrs, useId } from 'vue'
|
||||
import classes from './ToolBar.module.css'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
defaultValue,
|
||||
reset,
|
||||
orientation = 'horizontal',
|
||||
title,
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
defaultValue?: number
|
||||
reset?: () => void
|
||||
orientation?: Orientation
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const isVertical = computed(() => orientation === 'vertical')
|
||||
const orient = computed(() => orientation === 'vertical' ? 'vertical' : null)
|
||||
|
||||
const model = defineModel<number>()
|
||||
|
||||
function dblclickHandler(event: MouseEvent) {
|
||||
if (reset !== undefined) {
|
||||
event.preventDefault()
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
const markersListId = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" @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" />
|
||||
</datalist>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: initial;
|
||||
|
||||
height: 24px;
|
||||
|
||||
&[orient="vertical"] {
|
||||
writing-mode: vertical-lr;
|
||||
height: unset;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Idntical section ahead, but Chromium refuses to apply the style if it has multiple selectors */
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
content: ' ';
|
||||
background: #161616;
|
||||
border-style: inset;
|
||||
border-width: 1px;
|
||||
border-top-color: #212125;
|
||||
border-color: #2f2f35;
|
||||
border-bottom-color: #2f2f35;
|
||||
border-radius: 4px;
|
||||
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
content: ' ';
|
||||
background: #161616;
|
||||
border-style: inset;
|
||||
border-width: 1px;
|
||||
border-top-color: #212125;
|
||||
border-color: #2f2f35;
|
||||
border-bottom-color: #2f2f35;
|
||||
border-radius: 4px;
|
||||
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.slider[orient="vertical"]::-webkit-slider-runnable-track {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slider[orient="vertical"]::-moz-range-track {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(#919191 80%, #212121);
|
||||
|
||||
/* unique to -webkit */
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.slider[orient="vertical"]::-webkit-slider-thumb {
|
||||
margin-top: 0;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(#919191 80%, #212121);
|
||||
|
||||
/* unique to -moz */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.slider:not(:disabled):active::-webkit-slider-thumb {
|
||||
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
|
||||
}
|
||||
|
||||
.slider:not(:disabled):active::-moz-range-thumb {
|
||||
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
|
||||
}
|
||||
|
||||
.slider:focus-visible::-webkit-slider-thumb {
|
||||
outline: 4px solid #556cc9;
|
||||
}
|
||||
|
||||
.slider:focus-visible::-moz-range-thumb {
|
||||
outline: 4px solid #556cc9;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
@reference "tailwindcss";
|
||||
|
||||
@layer utilities {
|
||||
.tool-button {
|
||||
&:not(:disabled):active {
|
||||
color: white;
|
||||
/* Note: do not apply transform to the button itself, as it would affect its interaction target. */
|
||||
&>*:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.toolbar-control {
|
||||
color: #888888;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
|
||||
@apply flex-none w-12 h-12 rounded-full;
|
||||
|
||||
@variant hover {
|
||||
&:not(:disabled) {
|
||||
@apply text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&>svg {
|
||||
fill: currentColor;
|
||||
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
|
||||
@apply w-12 h-12;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-toggle {
|
||||
/* prevent CSS minifier/Tailwind purge from removing this rule */
|
||||
--toolbar-toggle-keep: 0;
|
||||
}
|
||||
|
||||
.toolbar-toggle-checked {
|
||||
color: white;
|
||||
|
||||
@variant hover {
|
||||
&:not(:disabled) {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool-button-small {
|
||||
color: #757575;
|
||||
background-color: #2c2c30;
|
||||
/* will-change: transform; */
|
||||
|
||||
@apply flex-none w-4 h-4 rounded-full;
|
||||
|
||||
@variant hover {
|
||||
&:not(:disabled) {
|
||||
color: #b9b9ba;
|
||||
background-color: #303034;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&>svg {
|
||||
fill: currentColor;
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import classes from './ToolBar.module.css'
|
||||
|
||||
defineProps<{
|
||||
icon: string | Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" :class="[classes.toolButton, classes.toolbarControl]">
|
||||
<component :is="icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import classes from './ToolBar.module.css'
|
||||
|
||||
defineProps<{
|
||||
icon: string | Component
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" :class="[classes.toolButton, classes.toolButtonSmall]" :disabled>
|
||||
<component :is="icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import classes from './ToolBar.module.css'
|
||||
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
icon: string | Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
classes.toolButton,
|
||||
classes.toolbarControl,
|
||||
classes.toolbarToggle,
|
||||
checked ? classes.toolbarToggleChecked : undefined,
|
||||
]"
|
||||
>
|
||||
<component :is="icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
// weird equality due to possible cache issues, and default being true
|
||||
const classForDisabled = computed<string | null>(() => track.Enabled === false ? 'tw:line-through' : null)
|
||||
|
||||
const trackPalettePreview = computed<string[]>(() => track.Palette.slice(0, 6))
|
||||
|
||||
// preview color per track name (reactive map)
|
||||
const previewColor = shallowRef<string | undefined>(undefined)
|
||||
|
||||
function updatePreview(pos: number) {
|
||||
if (!track || !track.Palette || track.Palette.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(pos)) {
|
||||
// reset to default
|
||||
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
|
||||
}
|
||||
</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"
|
||||
>
|
||||
<LibraryMusic class="tw:h-full tw:w-full card-preview" :style="{ color: previewColor }" />
|
||||
</div>
|
||||
|
||||
<!-- info -->
|
||||
<div style="grid-area: info;" class="tw:grid tw:grid-cols-1 tw:flex-col tw:gap-0 tw:items-end tw:justify-between">
|
||||
<div class="tw:flex tw:flex-row tw:gap-1 tw:items-center">
|
||||
<h3 class="tw:text-xl ellided" :class="classForDisabled" title="Song's codename as seen in game">
|
||||
{{ track.Name }}
|
||||
</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>
|
||||
<CardSeparator />
|
||||
<p class="ellided inactive-color" :class="classForDisabled" title="Artist">
|
||||
{{ track.Artist }}
|
||||
</p>
|
||||
<p class="ellided inactive-color" :class="classForDisabled" title="Song">
|
||||
{{ track.Song }}
|
||||
</p>
|
||||
<CardSeparator />
|
||||
</div>
|
||||
|
||||
<!-- 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, 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 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";
|
||||
|
||||
.card-grid {
|
||||
grid-template-areas:
|
||||
"preview info info"
|
||||
"preview palette timing";
|
||||
grid-template-columns: max-content 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
|
||||
@variant max-sm {
|
||||
grid-template-areas:
|
||||
"preview info info"
|
||||
"palette palette timing";
|
||||
}
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
fill: currentColor;
|
||||
color: var(--inactive-text-color);
|
||||
transition: color 0.1s linear;
|
||||
}
|
||||
|
||||
.card.hover-enabled:hover .card-preview,
|
||||
.card.selected .card-preview {
|
||||
color: var(--active-text-color);
|
||||
}
|
||||
|
||||
.ellided {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.inactive-color {
|
||||
color: var(--inactive-text-color);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
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>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
// Boost increases volume range from 0..1 up to 0..1.5
|
||||
enableBoost?: boolean
|
||||
}>()
|
||||
|
||||
/** Muted flag. When muted, volume slider shows zero value, but remains interactive. */
|
||||
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 mutedId = useId()
|
||||
const mutedTitle = computed(() => muted.value ? 'Unmute' : 'Mute')
|
||||
|
||||
function toggleMuted() {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function fromSteps(steps: number): number {
|
||||
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)
|
||||
},
|
||||
set(value: number) {
|
||||
volume.value = fromSteps(value)
|
||||
},
|
||||
})
|
||||
|
||||
function reset() {
|
||||
volume.value = 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"
|
||||
@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
|
||||
v-model.number="volumeDisplay"
|
||||
:min="0"
|
||||
:max="sliderSteps"
|
||||
:step="1"
|
||||
:reset="reset"
|
||||
:default-value
|
||||
title="Volume"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
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',
|
||||
} = defineProps<{
|
||||
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" :disabled="axis.isAtMin.value" @click="axis.zoomOut" />
|
||||
|
||||
<!-- skip :defaultValue="axis.default.discrete.value" because snapping makes dragging to negative values impossible -->
|
||||
<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" :disabled="axis.isAtMax.value" @click="axis.zoomIn" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<div class="tw:flex-1 tw:min-h-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import Panel from './Panel.vue'
|
||||
import ShadowedScrollView from './ShadowedScrollView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel>
|
||||
<template #toolbar>
|
||||
<slot name="toolbar" />
|
||||
</template>
|
||||
<ShadowedScrollView class="tw:h-full">
|
||||
<slot />
|
||||
</ShadowedScrollView>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
const scrollView = useTemplateRef('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");
|
||||
// measure();
|
||||
// }
|
||||
// });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:min-h-0 tw:min-w-0 tw:grid tw:grid-rows-1 tw:grid-cols-1">
|
||||
<!-- scrollable content view -->
|
||||
<div ref="scrollView" class="tw:overflow-scroll tw:min-h-0" style="grid-row: 1; grid-column: 1;">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shadow-top,
|
||||
.shadow-right,
|
||||
.shadow-bottom,
|
||||
.shadow-left {
|
||||
--shadow-darkest: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.shadow-top {
|
||||
background-image: linear-gradient(to top, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-right {
|
||||
background-image: linear-gradient(to right, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-bottom {
|
||||
background-image: linear-gradient(to bottom, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-left {
|
||||
background-image: linear-gradient(to left, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:bg-(--toolbar-background-color) tw:border-b tw:border-(--view-separator-color)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
<script lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
type Events = {
|
||||
'scroll-sync': ScrollSyncEvent
|
||||
}
|
||||
|
||||
const emitter = mitt<Events>()
|
||||
|
||||
function useEvent<Key extends keyof Events>(
|
||||
type: Key,
|
||||
handler: Handler<Events[Key]>,
|
||||
): void {
|
||||
const { on, off } = emitter
|
||||
|
||||
onMounted(() => {
|
||||
on(type, handler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
off(type, handler)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
// eslint-disable-next-line import/first, import/no-duplicates
|
||||
import { useId, useTemplateRef } from 'vue'
|
||||
|
||||
const {
|
||||
proportional,
|
||||
vertical,
|
||||
horizontal,
|
||||
group,
|
||||
} = defineProps<{
|
||||
proportional?: boolean
|
||||
vertical?: boolean
|
||||
horizontal?: boolean
|
||||
group: string
|
||||
}>()
|
||||
|
||||
const uuid = useId()
|
||||
const nodeRef = useTemplateRef('scroll-sync-container')
|
||||
|
||||
defineExpose({
|
||||
scrollTo: (options: ScrollToOptions) => {
|
||||
nodeRef.value?.scrollTo(options)
|
||||
},
|
||||
})
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
useRafFn(() => {
|
||||
const {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
scrollLeft,
|
||||
scrollWidth,
|
||||
clientWidth,
|
||||
offsetHeight,
|
||||
offsetWidth,
|
||||
} = event.target as HTMLElement
|
||||
|
||||
emitter.emit('scroll-sync', {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
scrollLeft,
|
||||
scrollWidth,
|
||||
clientWidth,
|
||||
barHeight: offsetHeight - clientHeight,
|
||||
barWidth: offsetWidth - clientWidth,
|
||||
emitter: uuid,
|
||||
group,
|
||||
})
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
useEvent('scroll-sync', (event: ScrollSyncEvent) => {
|
||||
const node = nodeRef.value
|
||||
|
||||
if (event.group !== group || event.emitter === uuid || node === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
scrollLeft,
|
||||
scrollWidth,
|
||||
clientWidth,
|
||||
barHeight,
|
||||
barWidth,
|
||||
} = event
|
||||
|
||||
// from https://github.com/okonet/react-scroll-sync
|
||||
const scrollTopOffset = scrollHeight - clientHeight
|
||||
const scrollLeftOffset = scrollWidth - clientWidth
|
||||
|
||||
/* Calculate the actual pane height */
|
||||
const paneHeight = node.scrollHeight - clientHeight
|
||||
const paneWidth = node.scrollWidth - clientWidth
|
||||
|
||||
/* Adjust the scrollTop position of it accordingly */
|
||||
node.removeEventListener('scroll', handleScroll)
|
||||
if (vertical && scrollTopOffset > barHeight) {
|
||||
node.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop
|
||||
}
|
||||
if (horizontal && scrollLeftOffset > barWidth) {
|
||||
node.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft
|
||||
}
|
||||
useRafFn(() => {
|
||||
node.addEventListener('scroll', handleScroll)
|
||||
}, { once: true })
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const node = nodeRef.value
|
||||
node!.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scroll-sync-container" class="scroll-sync-container">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scroll-sync-container {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import VolumeSlider from '@/components/library/VolumeSlider.vue'
|
||||
import { useTrackStore } from '@/store/TrackStore'
|
||||
|
||||
const trackStore = useTrackStore()
|
||||
|
||||
const muted = computed<boolean>({
|
||||
get() {
|
||||
return trackStore.muted
|
||||
},
|
||||
set(muted: boolean) {
|
||||
trackStore.setMuted(muted)
|
||||
},
|
||||
})
|
||||
|
||||
const volume = computed<number>({
|
||||
get() {
|
||||
return trackStore.volume
|
||||
},
|
||||
set(volume: number) {
|
||||
trackStore.setVolume(volume)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VolumeSlider v-model:muted="muted" v-model:volume="volume" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTimelineStore } from '@/store/TimelineStore'
|
||||
|
||||
const {
|
||||
positionSeconds,
|
||||
knob = true,
|
||||
hidden = false,
|
||||
} = defineProps<{
|
||||
// position in absolute seconds
|
||||
positionSeconds: number
|
||||
knob?: boolean
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
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 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 -->
|
||||
<div class="tw:absolute tw:px-2.5 tw:z-1" :style="viewportSide === 'left' ? { left: 0 } : { right: 0 }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playhead {
|
||||
--timeline-playhead-fill: #e64b3d;
|
||||
--timeline-playhead-border1-color: #e64b3d;
|
||||
--timeline-playhead-border1-opacity: 24%;
|
||||
--timeline-playhead-border2-color: #040000;
|
||||
--timeline-playhead-border2-opacity: 36%;
|
||||
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
will-change: transform, visibility;
|
||||
/* pointer-events: none; */
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const timeline = useTimelineStore()
|
||||
|
||||
const {
|
||||
headerHeight,
|
||||
sidebarWidth,
|
||||
viewportScrollOffsetTop,
|
||||
viewportScrollOffsetLeft,
|
||||
contentWidthIncludingEmptySpacePx,
|
||||
visibleTracks,
|
||||
} = storeToRefs(timeline)
|
||||
// nested composable marked with markRaw
|
||||
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis
|
||||
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
|
||||
|
||||
const timelineScrollGroup = useId()
|
||||
|
||||
const timelineRootElement = useTemplateRef('timelineRootElement')
|
||||
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView')
|
||||
const timelineScrollViewBounding = useElementBounding(timelineScrollView)
|
||||
watch(timelineScrollViewBounding.width, (value) => {
|
||||
timeline.viewportWidth = value
|
||||
})
|
||||
watch(timelineScrollViewBounding.height, (value) => {
|
||||
timeline.viewportHeight = value
|
||||
})
|
||||
const {
|
||||
arrivedState: timelineScrollViewArrivedState,
|
||||
x: timelineScrollViewOffsetLeft,
|
||||
y: timelineScrollViewOffsetTop,
|
||||
} = useScroll(() => timelineScrollView.value?.$el)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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`,
|
||||
}"
|
||||
>
|
||||
<!-- 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);"
|
||||
>
|
||||
<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" :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);"
|
||||
>
|
||||
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
|
||||
<TimelineHeader />
|
||||
</div>
|
||||
|
||||
<!-- <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;"
|
||||
>
|
||||
<!-- timeline content wrapper for good measure -->
|
||||
<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" :key="timelineTrack.name">
|
||||
<TimelineTrackView :timeline-track />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollSync>
|
||||
|
||||
<!-- 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: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 }"
|
||||
>
|
||||
<!-- actuals playback position -->
|
||||
<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">
|
||||
<Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" />
|
||||
</Playhead> -->
|
||||
|
||||
<!-- vertical bars of scroll shadow, on top of header, content AND playhead -->
|
||||
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1 / -1; grid-column: 2;">
|
||||
<div
|
||||
class="tw: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>
|
||||
|
||||
<!-- 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);"
|
||||
/>
|
||||
|
||||
<!-- right sidebar with vertical zoom slider -->
|
||||
<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);"
|
||||
>
|
||||
<ZoomSlider :axis="viewportZoomVertical" orientation="vertical" class="tw:w-full tw:min-h-0" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shadow-top,
|
||||
.shadow-right,
|
||||
.shadow-bottom,
|
||||
.shadow-left {
|
||||
--shadow-darkest: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.shadow-top {
|
||||
background-image: linear-gradient(to top, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-right {
|
||||
background-image: linear-gradient(to right, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-bottom {
|
||||
background-image: linear-gradient(to bottom, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-left {
|
||||
background-image: linear-gradient(to left, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script setup lang="ts">
|
||||
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 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',
|
||||
width: 32,
|
||||
})
|
||||
|
||||
function rewindToIntro() {
|
||||
timeline.rewindToIntro()
|
||||
}
|
||||
function rewindToWindUp() {
|
||||
timeline.rewindToWindUp()
|
||||
}
|
||||
function rewindToLoop() {
|
||||
timeline.rewindToLoop()
|
||||
}
|
||||
function toggle() {
|
||||
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"
|
||||
>
|
||||
<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" 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>
|
||||
<div class="tw:flex-1 tw:max-sm:w-full tw:flex tw:flex-row tw:gap-x-2">
|
||||
<Timestamp :seconds="timeline.playheadPosition" :beats="timeline.playheadPositionLoopOffsetBeats" />
|
||||
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
|
||||
{{ audioTrack?.Name }}
|
||||
</div>
|
||||
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
|
||||
<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" :right-sidebar />
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.description {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const { trackHeight } = storeToRefs(useTimelineStore())
|
||||
|
||||
const enCardinalRules = new Intl.PluralRules('en-US')
|
||||
|
||||
const clipStrings = new Map([
|
||||
['zero', 'Clips'],
|
||||
['one', 'Clip'],
|
||||
['two', 'Clips'],
|
||||
['few', 'Clips'],
|
||||
['other', 'Clips'],
|
||||
])
|
||||
|
||||
function getClipsCountString(n: number): string {
|
||||
return `${n} ${clipStrings.get(enCardinalRules.select(n)) ?? 'Clip'}`
|
||||
}
|
||||
|
||||
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 }"
|
||||
/>
|
||||
|
||||
<!-- 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);" />
|
||||
|
||||
<!-- content -->
|
||||
<div class="tw:flex-1 tw:min-w-0 tw:h-full tw:px-2 tw:text-sm" :class="big ? 'tw:py-2' : 'tw:justify-center'">
|
||||
<div class="tw:min-w-0 tw:select-none tw:truncate tw:font-semibold" :class="big ? null : 'tw:h-full tw:flex tw:items-center'">
|
||||
{{ timelineTrack.name }}
|
||||
</div>
|
||||
<div v-if="big" class="tw:min-w-0 tw:select-none tw:truncate tw:text-gray-400 clips-count">
|
||||
{{ getClipsCountString(timelineTrack.clips.length) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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);"
|
||||
/>
|
||||
|
||||
<!-- timeline track's clips -->
|
||||
<div class="tw:size-full" style="grid-row: 1; grid-column: 1; position: relative;">
|
||||
<!-- 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>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { formatBeats, formatTime } from '@/lib/AudioTrack'
|
||||
|
||||
defineProps<{
|
||||
seconds: number
|
||||
beats: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timestamp">
|
||||
<div title="seconds">
|
||||
{{ formatTime(seconds) }}
|
||||
</div>
|
||||
<div title="beats">
|
||||
{{ formatBeats(beats) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.timestamp {
|
||||
display: inline flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: end;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const timeline = useTimelineStore()
|
||||
const { audioTrack } = storeToRefs(timeline)
|
||||
|
||||
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 width = usePx(() => {
|
||||
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 isSelected = shallowRef(false)
|
||||
function selectClip() {
|
||||
// TODO: make selection manager
|
||||
isSelected.value = !isSelected.value
|
||||
}
|
||||
|
||||
// style:
|
||||
// - always thin outer border style
|
||||
// - regular (non-autorepeat):
|
||||
// - outer border is 50% black
|
||||
// - if not selected, do nothing
|
||||
// - if selected, red (inner) outline
|
||||
// - autorepeat:
|
||||
// - outer border is transparent (but still occupies 1px of space)
|
||||
// - always dashed thick outer border style
|
||||
// - 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 * Number.parseInt(outlineSelectedWidth.value, 10) : false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 }"
|
||||
/>
|
||||
|
||||
<component :is="contentView" :track="timelineTrack" :clip="timelineClip" :width="width.number" />
|
||||
|
||||
<!-- selection outline, above content -->
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selection {
|
||||
border: var(--timeline-clip-outline-selected);
|
||||
}
|
||||
|
||||
.autorepeat {
|
||||
border-width: var(--timeline-clip-outline-selected-width);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.selection-inner {
|
||||
border: 1px solid var(--timeline-clip-border-color-inner);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const label = computed(() => timelineClipLabel(track, clip))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- waveform -->
|
||||
<div v-if="clip.audioBuffer !== undefined" class="waveform-wrapper">
|
||||
<div class="waveform-content tw:overflow-hidden">
|
||||
<AudioWaveform :buffer="clip.audioBuffer" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- clip label -->
|
||||
<div class="label-wrapper">
|
||||
<div class="label-content tw:truncate" :style="{ display: width < 22 ? 'none' : undefined }" :title="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- clip line -->
|
||||
<BottomLine />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waveform-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 2px;
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
/* same as bottom line */
|
||||
padding-bottom: calc(var(--tw-spacing) * 5.5 + 2px);
|
||||
}
|
||||
|
||||
.waveform-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.label-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 2px;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.label-content {
|
||||
background-color: var(--timeline-clip-label-background-color);
|
||||
outline: 1px solid var(--timeline-clip-label-border-color);
|
||||
border-radius: 3px;
|
||||
text-align: start;
|
||||
max-width: fit-content;
|
||||
padding: 0 2px;
|
||||
font-size: 8pt;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<script setup lang="ts">
|
||||
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core'
|
||||
import { shallowRef, useTemplateRef, watchEffect } from 'vue'
|
||||
import { useWaveform } from '@/audio/AudioWaveform'
|
||||
|
||||
const {
|
||||
buffer,
|
||||
} = defineProps<{
|
||||
buffer: AudioBuffer
|
||||
}>()
|
||||
|
||||
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)
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.clearRect(0, 0, c.width, c.height)
|
||||
|
||||
ctx.fillStyle = '#ffffffd8'
|
||||
ctx.strokeStyle = 'transparent'
|
||||
|
||||
// fill first, slanted outline next
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const height = peakHeights[x]!
|
||||
// draw vertically centered
|
||||
const y = Math.round(halfHeight - height)
|
||||
ctx.fillRect(x, y, 1, height * 2)
|
||||
}
|
||||
|
||||
// outline
|
||||
ctx.fillStyle = 'transparent'
|
||||
ctx.strokeStyle = '#00000080'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
ctx.beginPath()
|
||||
|
||||
for (const sign of [-1, 1]) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// middle line
|
||||
ctx.fillStyle = '#a1a998'
|
||||
ctx.fillRect(0, Math.round(halfHeight), c.width, 1)
|
||||
|
||||
ctx.restore()
|
||||
}, 0)
|
||||
|
||||
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' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="tw:size-full" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<template>
|
||||
<!-- Just a cool line stretching all over the clip -->
|
||||
<div class="tw:absolute tw:w-full tw:h-0 tw:bottom-5.5 tw:border-b tw:border-(--timeline-clip-baseline-color)" />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
const label = computed(() => timelineClipLabel(track, clip))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- clip label -->
|
||||
<div class="label-wrapper">
|
||||
<div class="label-content tw:truncate" :style="{ display: width < 22 ? 'none' : undefined }" :title="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- clip line -->
|
||||
<BottomLine />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 2px;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.label-content {
|
||||
background-color: var(--timeline-clip-label-background-color);
|
||||
outline: 1px solid var(--timeline-clip-label-border-color);
|
||||
border-radius: 3px;
|
||||
text-align: start;
|
||||
max-width: fit-content;
|
||||
padding: 0 2px;
|
||||
font-size: 8pt;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||
|
||||
defineProps<{
|
||||
track: TimelineTrackData
|
||||
clip: TimelineClipData
|
||||
width: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view">
|
||||
Yahaha
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||
import Default from './Default.vue'
|
||||
|
||||
const {
|
||||
width,
|
||||
} = defineProps<{
|
||||
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;
|
||||
border-color: transparent;
|
||||
border-top-color: black;
|
||||
border-right-color: black;
|
||||
}
|
||||
|
||||
.fade-out-gradient {
|
||||
left: 0;
|
||||
top: 0;
|
||||
/* TODO: hardcoded bottom line offset */
|
||||
height: calc(100% - calc(var(--tw-spacing) * 5.5) - 1px);
|
||||
background-image:
|
||||
linear-gradient(to top right,
|
||||
transparent 49.9%,
|
||||
rgba(0, 0, 0, 0.5) 50%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const {
|
||||
clip,
|
||||
width,
|
||||
} = defineProps<{
|
||||
track: TimelineTrackData
|
||||
clip: TimelineClipData
|
||||
width: number
|
||||
}>()
|
||||
|
||||
const lyrics = computed(() => clip.name ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lyrics-wrapper">
|
||||
<span class="lyrics-content" :style="{ display: width < 22 ? 'none' : undefined }" :title="lyrics">
|
||||
{{ lyrics }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lyrics-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px 4px;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.lyrics-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: black;
|
||||
line-height: 1.2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: wrap;
|
||||
font-size: 8pt;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<script setup lang="ts">
|
||||
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
|
||||
}>()
|
||||
|
||||
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
|
||||
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 }
|
||||
}
|
||||
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 }"
|
||||
/>
|
||||
|
||||
<Default :track :clip :width />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-out {
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-top-color: black;
|
||||
border-right-color: black;
|
||||
}
|
||||
|
||||
.palette-gradient {
|
||||
left: 0;
|
||||
top: 0;
|
||||
/* TODO: hardcoded bottom line offset */
|
||||
height: 100%;
|
||||
height: calc(100% - calc(var(--tw-spacing) * 5.5) - 0px);
|
||||
background-image:
|
||||
linear-gradient(to right,
|
||||
var(--color-prev) 0%,
|
||||
var(--color-curr) var(--color-transition-out),
|
||||
var(--color-curr) var(--color-transition-in),
|
||||
var(--color-next) 100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1 @@
|
|||
Content view components for different TimelineClipView implementations.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Content view components for different TimelineClipView implementations.
|
||||
* @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'
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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
|
||||
}
|
||||
|
||||
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
|
||||
default:
|
||||
return Default
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import { toPx } from '@/lib/vue'
|
||||
|
||||
const {
|
||||
left,
|
||||
width,
|
||||
label,
|
||||
position,
|
||||
} = defineProps<{
|
||||
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="tick-major" />
|
||||
|
||||
<div class="tick tick-medium" />
|
||||
|
||||
<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;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
--gradient-direction: bottom;
|
||||
background-image:
|
||||
linear-gradient(to var(--gradient-direction),
|
||||
#979797,
|
||||
#97979700 70%);
|
||||
}
|
||||
|
||||
.bottom .tick-major {
|
||||
--gradient-direction: top;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
border-top: 1px solid var(--timeline-header-tick-edge-color);
|
||||
border-bottom: 1px solid var(--timeline-header-tick-edge-color);
|
||||
}
|
||||
|
||||
.tick-medium {
|
||||
left: 50%;
|
||||
height: 50%;
|
||||
background-color: #41434a;
|
||||
}
|
||||
|
||||
.tick-minor {
|
||||
height: 30%;
|
||||
background-color: #35373d;
|
||||
}
|
||||
|
||||
.tick-patch {
|
||||
height: 20%;
|
||||
background-color: #35373d;
|
||||
}
|
||||
|
||||
.bottom .tick-medium,
|
||||
.bottom .tick-minor,
|
||||
.bottom .tick-patch {
|
||||
top: unset;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.top .label {
|
||||
top: calc(var(--tw-spacing) * 0.25);
|
||||
}
|
||||
|
||||
.bottom .label {
|
||||
bottom: calc(var(--tw-spacing) * 0.25);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
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 allTicks = [
|
||||
{ ticks: useTimelineTicksSeconds(), position: 'top' },
|
||||
{ ticks: useTimelineTicksBeats(), position: 'bottom' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<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]" />
|
||||
|
||||
<!-- header markers -->
|
||||
<div class="tw:absolute tw:size-full">
|
||||
<!-- TODO: use marker id -->
|
||||
<MarkerBox v-for="marker in timeline.markers" :key="marker.markerIn" :marker />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineMarkerData } from '@/lib/Timeline'
|
||||
import { computed, shallowRef, useTemplateRef } from 'vue'
|
||||
import { markerToAbsoluteTime } from '@/lib/Timeline'
|
||||
import { usePx } from '@/lib/usePx'
|
||||
import { useTimelineStore } from '@/store/TimelineStore'
|
||||
import MarkerBoxSvg from './marker-box.svg'
|
||||
|
||||
const {
|
||||
marker,
|
||||
} = defineProps<{
|
||||
marker: TimelineMarkerData
|
||||
}>()
|
||||
|
||||
const timeline = useTimelineStore()
|
||||
|
||||
const left = usePx(() => {
|
||||
const seconds = markerToAbsoluteTime(timeline.audioTrack!, marker)
|
||||
const px = timeline.secondsToPixels(seconds)
|
||||
return px
|
||||
})
|
||||
|
||||
// const referenceClass = computed(() => marker.reference === 'absolute' ? 'marker-box-top' : 'marker-box-bottom');
|
||||
const positionClass = computed(() => marker.position === 'top' ? 'marker-box-top' : 'marker-box-bottom')
|
||||
|
||||
// TODO: selection manager
|
||||
const selected = shallowRef(false)
|
||||
|
||||
function toggle(_event: MouseEvent) {
|
||||
selected.value = !selected.value
|
||||
}
|
||||
|
||||
const element = useTemplateRef('element')
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
element.value?.setPointerCapture(event.pointerId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="element" class="tw:absolute marker-box" :class="[positionClass, { selected }]" :style="{
|
||||
left: left.string,
|
||||
color: marker.color,
|
||||
}" :title="marker.name" @click.prevent.stop="toggle" @pointerdown.prevent.stop="onPointerDown"
|
||||
>
|
||||
<MarkerBoxSvg />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marker-box {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 16px;
|
||||
transform: translateX(-5px) translateY(1px);
|
||||
--marker-stroke-color: #00000080;
|
||||
}
|
||||
|
||||
.marker-box-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.marker-box-top {
|
||||
top: 0;
|
||||
|
||||
&:deep(svg) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
--marker-stroke-color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineMarkerData } from '@/lib/Timeline'
|
||||
import { markerToAbsoluteTime } from '@/lib/Timeline'
|
||||
import { usePx } from '@/lib/usePx'
|
||||
import { useTimelineStore } from '@/store/TimelineStore'
|
||||
|
||||
const {
|
||||
marker,
|
||||
} = defineProps<{
|
||||
marker: TimelineMarkerData
|
||||
}>()
|
||||
|
||||
const timeline = useTimelineStore()
|
||||
|
||||
const left = usePx(() => {
|
||||
const seconds = markerToAbsoluteTime(timeline.audioTrack!, marker)
|
||||
const px = timeline.secondsToPixels(seconds)
|
||||
return px
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tw:absolute tw:w-0 tw:h-full tw:opacity-60 tw:border-l" :style="{
|
||||
left: left.string,
|
||||
borderColor: marker.color,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { Px } from '@/lib/units'
|
||||
import { toPx } from '@/lib/vue'
|
||||
|
||||
const {
|
||||
left,
|
||||
} = defineProps<{
|
||||
left: Px
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tw:absolute tw:w-0 tw:h-full tw:border-l tw:border-(--timeline-marker-beat-color)" :style="{
|
||||
left: toPx(left),
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<!-- Thin colored vertical lines stretching across the timeline, below the clips -->
|
||||
<script setup lang="ts">
|
||||
import { useTimelineTicksBeats } from '@/lib/useTimelineTicks'
|
||||
import { useTimelineStore } from '@/store/TimelineStore'
|
||||
import MarkerLine from './MarkerLine.vue'
|
||||
import TickLine from './TickLine.vue'
|
||||
|
||||
const ticks = useTimelineTicksBeats()
|
||||
|
||||
const timeline = useTimelineStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:absolute tw:size-full">
|
||||
<!-- timeline ticks for beats -->
|
||||
<div class="tw:size-full">
|
||||
<TickLine
|
||||
v-for="tick in ticks.ticks.value"
|
||||
:key="tick"
|
||||
:left="ticks.left(tick).value"
|
||||
/>
|
||||
<MarkerLine
|
||||
v-for="marker in timeline.markers"
|
||||
:key="marker.markerIn"
|
||||
:marker
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Components for rendering markers on a timeline.
|
||||
*
|
||||
* Markers are split into two layers:
|
||||
* - little pointy boxes on the timeline header, just below the playhead,
|
||||
* - and thin colored vertical lines stretching across the timeline, below the clips.
|
||||
*
|
||||
* Markers for beats only have hairline-styled layer, without boxes. They go below other markers with boxes.
|
||||
*/
|
||||
|
||||
export { default as TimelineMarkers } from './TimelineMarkers.vue'
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<svg viewBox="0 0 11 16" fill="currentColor">
|
||||
<path d="M 3 1 h 5 a 2 2 0 0 1 2 2 v 7.5 l -4.5 4.5 l -4.5 -4.5 V 3 a 2 2 0 0 1 2 -2 Z"
|
||||
stroke="var(--marker-stroke-color)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
|
|
@ -0,0 +1,3 @@
|
|||
export function easeInOutQuad(x: number): number {
|
||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { Handler } from 'mitt'
|
||||
import mitt from 'mitt'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
||||
export type Events = {
|
||||
scrollToTop: void
|
||||
}
|
||||
|
||||
export const emitter = mitt<Events>()
|
||||
|
||||
export function useEvent<Key extends keyof Events>(
|
||||
type: Key,
|
||||
handler: Handler<Events[Key]>,
|
||||
) {
|
||||
const { on, off } = emitter
|
||||
|
||||
on(type, handler)
|
||||
onBeforeUnmount(() => {
|
||||
off(type, handler)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import ArrowBack from '@material-design-icons/svg/round/arrow_back_ios.svg'
|
||||
import ArrowTop from '@material-design-icons/svg/round/keyboard_arrow_up.svg'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useScrollStore } from '@/store/ScrollStore'
|
||||
import { useGlobalHeaderStore } from './GlobalHeaderStore'
|
||||
|
||||
const route = useRoute()
|
||||
const arrowBackVisible = computed(() => {
|
||||
return route.path !== '/'
|
||||
})
|
||||
|
||||
const scrollStore = useScrollStore()
|
||||
const headerStore = useGlobalHeaderStore()
|
||||
useHead({
|
||||
title: () => headerStore.titleWithDefault,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="tw:flex tw:flex-row tw:gap-4">
|
||||
<RouterLink
|
||||
v-if="arrowBackVisible" to="/"
|
||||
class="header-button-back tw:flex-none tw:aspect-square tw:max-w-10 tw:flex tw:place-items-center tw:justify-center"
|
||||
>
|
||||
<ArrowBack class="tw:fill-current tw:w-10 tw:h-10" />
|
||||
</RouterLink>
|
||||
<h1 class="tw:flex-1 tw:p-2 tw:text-4xl tw:text-center">
|
||||
{{ headerStore.title }}<span v-if="headerStore.hasTitle" class="tw:max-sm:hidden">{{ headerStore.default }}</span>
|
||||
</h1>
|
||||
<button
|
||||
type="button" title="Scroll to Top"
|
||||
class="tw:flex-none tw:aspect-square tw:max-w-10 tw:flex tw:place-items-center tw:justify-center tw:disabled:opacity-30 tw:transition-opacity"
|
||||
:disabled="scrollStore.isAtTop" @click="scrollStore.scrollToTop"
|
||||
>
|
||||
<ArrowTop class="tw:fill-current tw:w-10 tw:h-10" />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
background-color: var(--header-background-color);
|
||||
border-bottom: var(--view-separator-border);
|
||||
}
|
||||
|
||||
.header-button-back:any-link {
|
||||
color: unset;
|
||||
}
|
||||
</style>
|
||||