Compare commits
1 Commits
dev
...
work/front
| Author | SHA1 | Date |
|---|---|---|
|
|
81870ecd47 |
|
|
@ -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/public/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,49 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Project assets
|
||||||
|
/public/MuzikaGromcheAudio/*
|
||||||
|
!/public/MuzikaGromcheAudio/.gitkeep
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 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 `public/MuzikaGromcheTracks.json` with the new JSON dump.
|
||||||
|
4. Run the following script to generate bare codenames file:
|
||||||
|
```sh
|
||||||
|
cat ./MuzikaGromcheTracks.json | jq '[.tracks[].Name | {(.): { "Artist": "", "Song": "" }}] | add' > MuzikaGromcheCodenamesBare.json
|
||||||
|
```
|
||||||
|
5. Add new codenames from the generated file above to `public/MuzikaGromcheCodenames.json` file.
|
||||||
|
|
||||||
|
|
||||||
|
### 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,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,52 @@
|
||||||
|
{
|
||||||
|
"name": "muzika-gromche-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/generate-icons.js",
|
||||||
|
"build": "npm run prebuild && vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"coverage": "vitest run --coverage",
|
||||||
|
"test:browser": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@material-design-icons/svg": "^0.14.15",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"@vitest/browser-playwright": "^4.0.15",
|
||||||
|
"@vitest/coverage-v8": "4.0.14",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"eslint": "~9.39.1",
|
||||||
|
"eslint-plugin-vue": "~10.5.1",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.1.14",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
|
"vite-svg-loader": "^5.1.0",
|
||||||
|
"vitest": "^4.0.15",
|
||||||
|
"vitest-browser-vue": "^2.0.1",
|
||||||
|
"vue-tsc": "^3.1.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.1.14"
|
||||||
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"core-js",
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Copy /Assets/ to this directory.
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
{
|
||||||
|
"AttentionPls1": {
|
||||||
|
"Artist": "Отпетые Мошенники",
|
||||||
|
"Song": "Обратите внимание"
|
||||||
|
},
|
||||||
|
"AttentionPls2": {
|
||||||
|
"Artist": "Отпетые Мошенники",
|
||||||
|
"Song": "Обратите внимание"
|
||||||
|
},
|
||||||
|
"BbIXODaHET": {
|
||||||
|
"Artist": "Сплин",
|
||||||
|
"Song": "Выхода нет"
|
||||||
|
},
|
||||||
|
"BeefLiver1": {
|
||||||
|
"Artist": "Imagine Dragons",
|
||||||
|
"Song": "Believer"
|
||||||
|
},
|
||||||
|
"BeefLiver3": {
|
||||||
|
"Artist": "Imagine Dragons",
|
||||||
|
"Song": "Believer"
|
||||||
|
},
|
||||||
|
"BeefLiver4": {
|
||||||
|
"Artist": "Imagine Dragons",
|
||||||
|
"Song": "Believer"
|
||||||
|
},
|
||||||
|
"Beha1": {
|
||||||
|
"Artist": "Жу-Жу",
|
||||||
|
"Song": "Ленинград ft. Глюк'oZа ft. ST"
|
||||||
|
},
|
||||||
|
"Beha2": {
|
||||||
|
"Artist": "Жу-Жу",
|
||||||
|
"Song": "Ленинград ft. Глюк'oZа ft. ST"
|
||||||
|
},
|
||||||
|
"Beha3": {
|
||||||
|
"Artist": "Жу-Жу",
|
||||||
|
"Song": "Ленинград ft. Глюк'oZа ft. ST"
|
||||||
|
},
|
||||||
|
"Chereshnya": {
|
||||||
|
"Artist": "Дискотека Авария",
|
||||||
|
"Song": "Малинки"
|
||||||
|
},
|
||||||
|
"DeployDestroy": {
|
||||||
|
"Artist": "Noize MC",
|
||||||
|
"Song": "Устрой дестрой"
|
||||||
|
},
|
||||||
|
"Durochka": {
|
||||||
|
"Artist": "Би-2",
|
||||||
|
"Song": "Дурочка"
|
||||||
|
},
|
||||||
|
"GodMode": {
|
||||||
|
"Artist": "Fall Out Boy",
|
||||||
|
"Song": "Immortals"
|
||||||
|
},
|
||||||
|
"Gorgorod": {
|
||||||
|
"Artist": "Город под подошвой",
|
||||||
|
"Song": "Oxxxymiron"
|
||||||
|
},
|
||||||
|
"Kach": {
|
||||||
|
"Artist": "Black Eyed Peas",
|
||||||
|
"Song": "Pump It"
|
||||||
|
},
|
||||||
|
"MoyaZhittya": {
|
||||||
|
"Artist": "Bon Jovi",
|
||||||
|
"Song": "It's My Life"
|
||||||
|
},
|
||||||
|
"MuzikaGromche": {
|
||||||
|
"Artist": "Пошлая Молли",
|
||||||
|
"Song": "Нон стоп"
|
||||||
|
},
|
||||||
|
"OnePartiyaUdar": {
|
||||||
|
"Artist": "One-Punch Man",
|
||||||
|
"Song": "Opening"
|
||||||
|
},
|
||||||
|
"Peretasovka": {
|
||||||
|
"Artist": "LMFAO",
|
||||||
|
"Song": "Party Rock Anthem"
|
||||||
|
},
|
||||||
|
"PWNED": {
|
||||||
|
"Artist": "CYBEЯIA",
|
||||||
|
"Song": "Russian Hackers"
|
||||||
|
},
|
||||||
|
"ReelGoon": {
|
||||||
|
"Artist": "John Shanks and Sheryl Crow",
|
||||||
|
"Song": "Real Gone"
|
||||||
|
},
|
||||||
|
"RiseAndShine": {
|
||||||
|
"Artist": "Fall Out Boy",
|
||||||
|
"Song": "The Phoenix"
|
||||||
|
},
|
||||||
|
"Song2": {
|
||||||
|
"Artist": "Витас",
|
||||||
|
"Song": "Опера #2"
|
||||||
|
},
|
||||||
|
"VseVZale": {
|
||||||
|
"Artist": "Дискотека Авария",
|
||||||
|
"Song": " Х.Х.Х.И.Р.Н.Р."
|
||||||
|
},
|
||||||
|
"Whistle": {
|
||||||
|
"Artist": "Flo Rida",
|
||||||
|
"Song": "Whistle"
|
||||||
|
},
|
||||||
|
"Yalgaar": {
|
||||||
|
"Artist": "Ajey Nagar and Wily Frenzy",
|
||||||
|
"Song": "Yalgaar"
|
||||||
|
},
|
||||||
|
"ZmeiGorynich": {
|
||||||
|
"Artist": "aespa",
|
||||||
|
"Song": "Black Mamba"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -0,0 +1,50 @@
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import toIco from 'png-to-ico';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const sourceIcon = path.resolve(__dirname, '../../icon.png');
|
||||||
|
const outputDir = path.resolve(__dirname, '../public');
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate PNGs
|
||||||
|
const sizes = [32, 192, 256];
|
||||||
|
for (const size of sizes) {
|
||||||
|
const outputPath = path.join(outputDir, `icon-${size}.png`);
|
||||||
|
await sharp(sourceIcon)
|
||||||
|
.resize(size, size)
|
||||||
|
.toFile(outputPath);
|
||||||
|
console.log(`Generated ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate apple-touch-icon
|
||||||
|
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png');
|
||||||
|
await sharp(sourceIcon)
|
||||||
|
.resize(180, 180)
|
||||||
|
.toFile(appleIconPath);
|
||||||
|
console.log(`Generated ${appleIconPath}`);
|
||||||
|
|
||||||
|
// Generate favicon.ico
|
||||||
|
const icoSizes = [16, 24, 32, 48];
|
||||||
|
const buffers = await Promise.all(icoSizes.map(size =>
|
||||||
|
sharp(sourceIcon)
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toBuffer()
|
||||||
|
));
|
||||||
|
const icoBuffer = await toIco(buffers);
|
||||||
|
const icoPath = path.join(outputDir, 'favicon.ico');
|
||||||
|
await fs.writeFile(icoPath, icoBuffer);
|
||||||
|
console.log(`Generated ${icoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIcons().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import GlobalHeader from '@/components/GlobalHeader.vue';
|
||||||
|
</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,533 @@
|
||||||
|
/*
|
||||||
|
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 AudioTrack, useWrapTime, wrapTimeFn } from "@/lib/AudioTrack";
|
||||||
|
import type { Seconds } from "@/lib/units";
|
||||||
|
import {
|
||||||
|
type ConfigurableWindow,
|
||||||
|
tryOnScopeDispose,
|
||||||
|
useRafFn,
|
||||||
|
useThrottleFn,
|
||||||
|
watchImmediate,
|
||||||
|
} from "@vueuse/core";
|
||||||
|
import {
|
||||||
|
type MaybeRefOrGetter,
|
||||||
|
type Ref,
|
||||||
|
shallowRef,
|
||||||
|
toValue,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
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 (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
currentLoop?.stop(when);
|
||||||
|
} catch (e) {
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(seekPosition: Seconds, options?: SeekOptions) {
|
||||||
|
const {
|
||||||
|
scrub = false,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
|
stop({ rememberPosition: false });
|
||||||
|
|
||||||
|
startPosition.value = seekPosition;
|
||||||
|
|
||||||
|
if (scrub) {
|
||||||
|
doThrottledScrub();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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: Function[] = [];
|
||||||
|
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,191 @@
|
||||||
|
import type { Px } from "@/lib/units";
|
||||||
|
import { useWeakCache } from "@/lib/useWeakCache";
|
||||||
|
import { type Fn, tryOnScopeDispose, watchImmediate } from "@vueuse/core";
|
||||||
|
import type { MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, shallowRef, toValue, triggerRef } from "vue";
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
|
||||||
|
// 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 = NaN;
|
||||||
|
} else {
|
||||||
|
timeoutID = setTimeout(stepBatchOfBlocks, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutID: number = NaN;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDone,
|
||||||
|
peaks,
|
||||||
|
run() {
|
||||||
|
users += 1;
|
||||||
|
|
||||||
|
if (Number.isNaN(timeoutID) && users === 1) {
|
||||||
|
timeoutID = setTimeout(stepBatchOfBlocks, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
users -= 1;
|
||||||
|
|
||||||
|
if (!Number.isNaN(timeoutID) && users === 0) {
|
||||||
|
window.clearTimeout(timeoutID);
|
||||||
|
timeoutID = NaN;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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,20 @@
|
||||||
|
<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,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
// import OpenInNew from '@material-design-icons/svg/outlined/open_in_new.svg?url&inline';
|
||||||
|
// import OpenInNew2 from '@material-design-icons/svg/outlined/open_in_new.svg';
|
||||||
|
|
||||||
|
const trackStore = useTrackStore();
|
||||||
|
const { version } = storeToRefs(trackStore);
|
||||||
|
|
||||||
|
const product = "MuzikaGromche";
|
||||||
|
const productLink = "https://thunderstore.io/c/lethal-company/p/Ratijas/MuzikaGromche/";
|
||||||
|
const year = 2025;
|
||||||
|
const author = "Ratijas";
|
||||||
|
const authorLink = "https://ratijas.me";
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<footer>
|
||||||
|
<!-- TODO: A bug/omission somewhere in Vite/Rolldown/SVGO prevents Vite/SVGO plugin config from injecting { "fill": "currentColor" } into ?inline imported SVG. -->
|
||||||
|
<div
|
||||||
|
class="tw:py-2 tw:px-4 tw:gap-2 tw:flex tw:flex-row tw:max-sm:flex-col tw:flex-wrap tw:items-center tw:justify-center toolbar-background"
|
||||||
|
style="border-top: var(--view-separator-border);">
|
||||||
|
<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,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTitle, DEFAULT_TITLE } from '@/router';
|
||||||
|
import { useScrollStore } from '@/store/ScrollStore';
|
||||||
|
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 { computed } from 'vue';
|
||||||
|
import { RouterLink, useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const title = useTitle();
|
||||||
|
const route = useRoute();
|
||||||
|
const arrowBackVisible = computed(() => {
|
||||||
|
return route.path !== '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollStore = useScrollStore();
|
||||||
|
</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">
|
||||||
|
{{ title }}<span v-if="title !== DEFAULT_TITLE" class="tw:max-sm:hidden" > — {{ DEFAULT_TITLE }}</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>
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<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,39 @@
|
||||||
|
<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></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,42 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Search from '@material-design-icons/svg/outlined/search.svg';
|
||||||
|
import Clear from '@material-design-icons/svg/outlined/clear.svg';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
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 type="text" role="searchbox" v-model="model" :placeholder class="tw:flex-1 tw:min-w-0" />
|
||||||
|
<button type="button" role="button" name="clear" aria-roledescription="Clear search field" tabindex="-1" title="Clear"
|
||||||
|
@click="clear" :disabled="clearDisabled" class="button">
|
||||||
|
<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,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Timestamp from '@/components/timeline/Timestamp.vue';
|
||||||
|
import { LANGUAGES, type AudioTrack } from '@/lib/AudioTrack';
|
||||||
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
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" :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 type="checkbox" id="isExplicit" :checked="track.IsExplicit" :disabled="!edit" />
|
||||||
|
<label for="isExplicit" style="flex: 1;">
|
||||||
|
<div title="Warning: Explicit Content" class="tw:flex-none">
|
||||||
|
<Explicit class="tw:w-6 tw:h-6 tw:fill-current" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</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,51 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ScrollablePanel from "@/components/library/panel/ScrollablePanel.vue";
|
||||||
|
import Construction from "@material-design-icons/svg/round/construction.svg";
|
||||||
|
import { computed, shallowRef } from "vue";
|
||||||
|
import AudioTrack from "./views/AudioTrack.vue";
|
||||||
|
import { useTimelineStore } from "@/store/TimelineStore";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
// TODO: use selection (inspector?) manager
|
||||||
|
const selection = shallowRef<object | null>({});
|
||||||
|
|
||||||
|
const timeline = useTimelineStore();
|
||||||
|
const { audioTrack, tracksMap } = storeToRefs(timeline);
|
||||||
|
|
||||||
|
const introClip = computed(() => tracksMap.value.intro.clips[0]);
|
||||||
|
</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" :audioTrack />
|
||||||
|
<!-- <Clip v-if="introClip" :clip="introClip" /> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</ScrollablePanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<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,20 @@
|
||||||
|
<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,32 @@
|
||||||
|
<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,23 @@
|
||||||
|
<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" @click="control.action" :disabled="control.disabled" class="control-button">
|
||||||
|
<component v-if="control.icon" :is="control.icon" class="control-button__icon" />
|
||||||
|
<span class="control-button__text">{{ control.text }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BaseNamedControlView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<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 :control :id>
|
||||||
|
<label class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
|
||||||
|
:class="{ 'control-label__disabled': control.disabled }">
|
||||||
|
<input type="checkbox" v-model="control.ref" :id :disabled="control.disabled" />
|
||||||
|
<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,29 @@
|
||||||
|
<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 :control :id>
|
||||||
|
<div>
|
||||||
|
<select :id v-model="control.ref.value" :disabled="control.disabled"
|
||||||
|
class="tw:w-full tw:max-w-full control-select">
|
||||||
|
<option v-for="option in control.options" :value="option">
|
||||||
|
{{ option }}
|
||||||
|
<!-- and very long text what gonna happen -->
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</BaseNamedControlView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<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,14 @@
|
||||||
|
<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,24 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { NumberControl } from "@/components/inspector/controls";
|
||||||
|
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||||
|
import { useId } from "vue";
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
} = defineProps<{
|
||||||
|
control: NumberControl;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseNamedControlView :control :id>
|
||||||
|
<div>
|
||||||
|
<input :id type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
||||||
|
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-20" />
|
||||||
|
</div>
|
||||||
|
</BaseNamedControlView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RangeControl } from "@/components/inspector/controls";
|
||||||
|
import Slider from "@/components/library/Slider.vue";
|
||||||
|
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||||
|
import { useId } from "vue";
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
} = defineProps<{
|
||||||
|
control: RangeControl;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseNamedControlView :control :id>
|
||||||
|
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
|
||||||
|
<Slider :id v-model.number="control.ref.value"
|
||||||
|
@update:model-value="(value) => control.ref.value = value ?? control.default" :min="control.min"
|
||||||
|
:max="control.max" :step="0.01" :defaultValue="0" :disabled="control.disabled || control.readonly"
|
||||||
|
class="tw:flex-1 tw:self-end" />
|
||||||
|
<input type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
||||||
|
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-14" />
|
||||||
|
</div>
|
||||||
|
</BaseNamedControlView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<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 rows="4" v-model="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
||||||
|
class="tw:w-full tw:max-w-full tw:block tw:resize-none input-text" :class="{ 'tw:font-mono': control.monospace }"
|
||||||
|
spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
</BaseNamedControlView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<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" />
|
||||||
|
</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,183 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AudioTrack } from "@/lib/AudioTrack";
|
||||||
|
import * as Easing from "@/lib/easing";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { Controls } from "../controls";
|
||||||
|
import ControlsView from "../controls/ControlsView.vue";
|
||||||
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
||||||
|
|
||||||
|
const {
|
||||||
|
audioTrack,
|
||||||
|
} = defineProps<{
|
||||||
|
audioTrack: AudioTrack,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const easing = ref(audioTrack.ColorTransitionEasing);
|
||||||
|
|
||||||
|
const controls: 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),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "checkbox",
|
||||||
|
key: "IsExplicit",
|
||||||
|
name: "Is Explicit",
|
||||||
|
icon: Explicit,
|
||||||
|
ref: ref(audioTrack.IsExplicit),
|
||||||
|
label: "Explicit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "hr",
|
||||||
|
key: "audioTrack.hr.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "dropdown",
|
||||||
|
key: "Easing",
|
||||||
|
name: "Easing",
|
||||||
|
readonly: true,
|
||||||
|
ref: easing,
|
||||||
|
options: Easing.allNames,
|
||||||
|
},
|
||||||
|
// TODO: remove
|
||||||
|
// {
|
||||||
|
// kind: "dropdown",
|
||||||
|
// key: "Easing2",
|
||||||
|
// name: "Easing",
|
||||||
|
// readonly: true,
|
||||||
|
// ref: easing,
|
||||||
|
// options: Easing.allNames,
|
||||||
|
// disabled: true,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
kind: "number",
|
||||||
|
key: "LyricsIn",
|
||||||
|
name: "Lyrics In",
|
||||||
|
ref: ref(audioTrack.Lyrics[0]?.[0] ?? 0),
|
||||||
|
min: 0,
|
||||||
|
max: 1000,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "textarea",
|
||||||
|
key: "LyricsText",
|
||||||
|
name: "Lyrics Text",
|
||||||
|
ref: ref(audioTrack.Lyrics[0]?.[1] ?? ""),
|
||||||
|
monospace: true,
|
||||||
|
readonly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "textarea",
|
||||||
|
key: "Lyrics2",
|
||||||
|
name: "Lyrics2",
|
||||||
|
ref: ref(audioTrack.Lyrics[1]?.[1] ?? ""),
|
||||||
|
monospace: true,
|
||||||
|
disabled: true,
|
||||||
|
readonly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "button",
|
||||||
|
key: "Clear",
|
||||||
|
name: "",
|
||||||
|
text: "Clear",
|
||||||
|
icon: Explicit,
|
||||||
|
action: () => {
|
||||||
|
console.log("Trigger death screen");
|
||||||
|
},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "button",
|
||||||
|
key: "Clear2",
|
||||||
|
name: "",
|
||||||
|
text: "Trigger death screen",
|
||||||
|
// icon: Explicit,
|
||||||
|
action: () => {
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "number",
|
||||||
|
key: "FadeOutBeat",
|
||||||
|
name: "Fade Out Beat",
|
||||||
|
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: "number",
|
||||||
|
// key: "FadeOutDuration2",
|
||||||
|
// name: "Fade Out Duration",
|
||||||
|
// min: 0,
|
||||||
|
// max: 1000,
|
||||||
|
// default: 2,
|
||||||
|
// ref: ref(audioTrack.FadeOutDuration),
|
||||||
|
// readonly: true,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// kind: "number",
|
||||||
|
// key: "FadeOutDuration3",
|
||||||
|
// name: "Fade Out Duration",
|
||||||
|
// min: 0,
|
||||||
|
// max: 1000,
|
||||||
|
// default: 2,
|
||||||
|
// ref: ref(audioTrack.FadeOutDuration),
|
||||||
|
// disabled: true,
|
||||||
|
// readonly: true,
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
</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,188 @@
|
||||||
|
<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>(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 = NaN;
|
||||||
|
if (playheadEl.value) {
|
||||||
|
playheadEl.value.style.left = "";
|
||||||
|
}
|
||||||
|
emit('playhead', 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>
|
||||||
|
</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,14 @@
|
||||||
|
<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,13 @@
|
||||||
|
<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,92 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SectionHeader from '@/components/library/SectionHeader.vue';
|
||||||
|
import SearchField from '@/components/SearchField.vue';
|
||||||
|
import { type AudioTrack } from '@/lib/AudioTrack';
|
||||||
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
import FilterNone from '@material-design-icons/svg/outlined/filter_none.svg';
|
||||||
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
|
import TrackCard from './TrackCard.vue';
|
||||||
|
import Footer from '../Footer.vue';
|
||||||
|
|
||||||
|
const trackStore = useTrackStore();
|
||||||
|
|
||||||
|
const selectedTrackName = ref<string | null>(null);
|
||||||
|
// selectedTrackName.value = "HowLow";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<SectionHeader class="tw:col-span-full">{{ language }}</SectionHeader>
|
||||||
|
<TrackCard v-for="track in tracks" :track :selected="track.Name === selectedTrackName"
|
||||||
|
@select="selectedTrackName = track.Name" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<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,156 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useAttrs, useId } from 'vue';
|
||||||
|
import classes from './ToolBar.module.css';
|
||||||
|
import type { Orientation } from "./Slider";
|
||||||
|
|
||||||
|
const {
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
defaultValue,
|
||||||
|
reset,
|
||||||
|
orientation = "horizontal",
|
||||||
|
title,
|
||||||
|
} = defineProps<{
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
step?: number,
|
||||||
|
defaultValue?: number,
|
||||||
|
reset?: () => void,
|
||||||
|
orientation?: Orientation,
|
||||||
|
title?: string,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
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 type="range" :min :max :step v-model.number="model" :orient :title @dblclick="dblclickHandler"
|
||||||
|
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" />
|
||||||
|
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
|
||||||
|
<datalist :id="markersListId">
|
||||||
|
<option v-if="defaultValue !== undefined" :value="defaultValue"></option>
|
||||||
|
</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,80 @@
|
||||||
|
@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 {
|
||||||
|
}
|
||||||
|
.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,15 @@
|
||||||
|
<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,17 @@
|
||||||
|
<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,17 @@
|
||||||
|
<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,128 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Card from '@/components/library/Card.vue';
|
||||||
|
import ColorSwatch from '@/components/library/ColorSwatch.vue';
|
||||||
|
import { openTrack } from '@/router';
|
||||||
|
import { formatTime, timeSeriesIsEmpty, type AudioTrack } from '@/lib/AudioTrack';
|
||||||
|
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
||||||
|
import Lyrics from '@material-design-icons/svg/filled/lyrics.svg';
|
||||||
|
import AutoAwesome from '@material-design-icons/svg/filled/auto_awesome.svg';
|
||||||
|
import LibraryMusic from '@material-design-icons/svg/two-tone/library_music.svg';
|
||||||
|
import { computed, shallowRef } from 'vue';
|
||||||
|
import TrackCardBadge from './TrackCardBadge.vue';
|
||||||
|
import CardSeparator from './CardSeparator.vue';
|
||||||
|
|
||||||
|
const {
|
||||||
|
track,
|
||||||
|
} = defineProps<{
|
||||||
|
track: AudioTrack,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hasLyrics = computed<boolean>(() => track.Lyrics.length !== 0);
|
||||||
|
const hasEffects = computed<boolean>(() =>
|
||||||
|
timeSeriesIsEmpty(track.DrunknessLoopOffsetTimeSeries) ||
|
||||||
|
timeSeriesIsEmpty(track.CondensationLoopOffsetTimeSeries)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (!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" 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="hasLyrics" :icon="Lyrics" title="Contains Lyrics" />
|
||||||
|
<TrackCardBadge v-if="hasEffects" :icon="AutoAwesome" title="Contains Visual Effects" />
|
||||||
|
</div>
|
||||||
|
<CardSeparator />
|
||||||
|
<p class="ellided inactive-color" title="Artist">
|
||||||
|
{{ track.Artist }}
|
||||||
|
</p>
|
||||||
|
<p class="ellided inactive-color" 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 in trackPalettePreview" :color />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- timing -->
|
||||||
|
<div style="grid-area: timing;" class="tw:flex tw:flex-col tw:text-xs">
|
||||||
|
<div title="Intro duration" class="tw:font-mono">{{ formatTime(track.WindUpTimer) }}</div>
|
||||||
|
<div title="Loop offset" class="tw:font-mono" v-if="track.LoopOffset > 0">{{ track.LoopOffset }} beats</div>
|
||||||
|
<div title="Loop duration" class="tw:font-mono">{{ formatTime(track.FileDurationLoop) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</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,15 @@
|
||||||
|
<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,73 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Slider from '@/components/library/Slider.vue';
|
||||||
|
import VolumeDown from '@material-design-icons/svg/outlined/volume_down.svg';
|
||||||
|
import VolumeMute from '@material-design-icons/svg/outlined/volume_mute.svg';
|
||||||
|
import VolumeOff from '@material-design-icons/svg/outlined/volume_off.svg';
|
||||||
|
import VolumeUp from '@material-design-icons/svg/outlined/volume_up.svg';
|
||||||
|
import { computed, useId } from 'vue';
|
||||||
|
import classes from './ToolBar.module.css';
|
||||||
|
|
||||||
|
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" v-on:click="toggleMuted" />
|
||||||
|
<label :for="mutedId" :class="[classes.toolbarControl, classes.toolButton]" :title="mutedTitle" tabindex="0">
|
||||||
|
<VolumeOff v-if="muted" class="tw:text-[#e64b3d]" />
|
||||||
|
<!-- transforms are needed because icons are centered rather than aligned with each other -->
|
||||||
|
<VolumeMute v-else-if="volume < 0.33" style="transform: translateX(-8px);" />
|
||||||
|
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
|
||||||
|
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
|
||||||
|
</label>
|
||||||
|
<Slider :min="0" :max="sliderSteps" :step="1" v-model.number="volumeDisplay" :reset="reset" :defaultValue
|
||||||
|
title="Volume" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Slider from '@/components/library/Slider.vue';
|
||||||
|
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
||||||
|
import Add from "@material-design-icons/svg/filled/add.svg";
|
||||||
|
import Remove from "@material-design-icons/svg/filled/remove.svg";
|
||||||
|
import type { Orientation } from "./Slider";
|
||||||
|
import ToolButtonSmall from './ToolButtonSmall.vue';
|
||||||
|
|
||||||
|
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" @click="axis.zoomOut" :disabled="axis.isAtMin.value" />
|
||||||
|
|
||||||
|
<!-- skip :defaultValue="axis.default.discrete.value" because snapping makes dragging to negative values impossible -->
|
||||||
|
<Slider :min="axis.min.discrete.value" :max="axis.max.discrete.value" :step="axis.stepSmall.discrete.value"
|
||||||
|
v-model.number="axis.zoom.discrete.value" :orientation :reset="axis.reset" />
|
||||||
|
|
||||||
|
<ToolButtonSmall :icon="Add" title="Zoom In" @click="axis.zoomIn" :disabled="axis.isAtMax.value" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<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,18 @@
|
||||||
|
<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 { useInterval, useScroll } from "@vueuse/core";
|
||||||
|
import { useTemplateRef } from "vue";
|
||||||
|
|
||||||
|
const scrollView = useTemplateRef('scrollView');
|
||||||
|
|
||||||
|
const { arrivedState, measure } = useScroll(scrollView);
|
||||||
|
|
||||||
|
// useScroll.arrivedState can get stale,
|
||||||
|
// see: https://github.com/vueuse/vueuse/issues/4265#issuecomment-3618168624
|
||||||
|
// 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bottom shadow -->
|
||||||
|
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.bottom }">
|
||||||
|
<div class="tw:h-4 tw:w-full shadow-top"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- left shadow -->
|
||||||
|
<div class="tw:absolute tw:left-0 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.left }">
|
||||||
|
<div class="tw:w-4 tw:h-full shadow-right"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</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,145 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import mitt, { type Handler } from "mitt";
|
||||||
|
import { useRafFn } from "@vueuse/core";
|
||||||
|
import { onMounted, onBeforeUnmount } 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">
|
||||||
|
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></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.scroll-sync-container {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import VolumeSlider from '@/components/library/VolumeSlider.vue';
|
||||||
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
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,56 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
|
||||||
|
const timeline = useTimelineStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
positionSeconds,
|
||||||
|
knob = true,
|
||||||
|
hidden = false,
|
||||||
|
} = defineProps<{
|
||||||
|
// position in absolute seconds
|
||||||
|
positionSeconds: number,
|
||||||
|
knob?: boolean,
|
||||||
|
hidden?: boolean,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const positionPixels = computed(() => timeline.secondsToPixels(positionSeconds));
|
||||||
|
const viewportSide = computed(() => timeline.viewportSide(positionSeconds));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playhead" :style="{
|
||||||
|
'transform': `translateX(${positionPixels}px)`,
|
||||||
|
'visibility': hidden ? 'hidden' : undefined,
|
||||||
|
}">
|
||||||
|
<div class="tw:absolute tw:flex tw:flex-col tw:h-full" style="width: 17px; transform: translateX(-8px)"
|
||||||
|
:style="{ paddingTop: knob ? 0 : 0 /* '1px' */ }">
|
||||||
|
<img src="@/assets/playhead-top.png" class="tw:flex-none" v-if="knob" />
|
||||||
|
<img src="@/assets/playhead-main.png" class="tw:flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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,224 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ZoomSlider from '@/components/library/ZoomSlider.vue';
|
||||||
|
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
|
||||||
|
import Playhead from '@/components/timeline/Playhead.vue';
|
||||||
|
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
|
||||||
|
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
|
||||||
|
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
|
||||||
|
import { useTimelineScrubbing } from "@/lib/useTimelineScrubbing";
|
||||||
|
import { useVeiwportWheel } from '@/lib/useVeiwportWheel';
|
||||||
|
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
||||||
|
import { bindTwoWay, toPx } from '@/lib/vue';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { useElementBounding, useScroll } from '@vueuse/core';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useId, useTemplateRef, watch } from 'vue';
|
||||||
|
import TimelineTrackHeader from './TimelineTrackHeader.vue';
|
||||||
|
import TimelineTrackView from './TimelineTrackView.vue';
|
||||||
|
import TimelineMarkers from './markers/TimelineMarkers.vue';
|
||||||
|
|
||||||
|
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">
|
||||||
|
<TimelineTrackHeader :timelineTrack />
|
||||||
|
</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">
|
||||||
|
<TimelineTrackView :timelineTrack />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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 :positionSeconds="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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- empty cell at the top right -->
|
||||||
|
<div v-if="rightSidebar.visible.value" class="toolbar-background"
|
||||||
|
style="grid-row: 1; grid-column: 3; border-bottom: var(--view-separator-border); border-left: var(--view-separator-border);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 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,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { togglePlayStop } from '@/audio/AudioEngine';
|
||||||
|
import ToolButton from '@/components/library/ToolButton.vue';
|
||||||
|
import ToolToggle from '@/components/library/ToolToggle.vue';
|
||||||
|
import Timestamp from '@/components/timeline/Timestamp.vue';
|
||||||
|
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg';
|
||||||
|
import Play from '@material-design-icons/svg/outlined/play_circle.svg';
|
||||||
|
import Replay from '@material-design-icons/svg/outlined/replay.svg';
|
||||||
|
import Restart from '@material-design-icons/svg/outlined/restart_alt.svg';
|
||||||
|
import ViewSidebar from '@material-design-icons/svg/outlined/view_sidebar.svg';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import MasterVolumeSlider from './MasterVolumeSlider.vue';
|
||||||
|
import Timeline from './Timeline.vue';
|
||||||
|
import Panel from "@/components/library/panel/Panel.vue";
|
||||||
|
|
||||||
|
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" @click="rewindToIntro" title="Rewind to Intro" />
|
||||||
|
<ToolButton :icon="Restart" @click="rewindToWindUp"
|
||||||
|
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
|
||||||
|
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
|
||||||
|
<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" @click="rightSidebar.toggle()"
|
||||||
|
:title="rightSidebar.toggleActionString.value" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Timeline class="tw:min-h-0 tw:size-full" :rightSidebar />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.description {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
import { toPx } from '@/lib/vue';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
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,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
import { toPx } from '@/lib/vue';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import TimelineClipView from './clip/TimelineClipView.vue';
|
||||||
|
|
||||||
|
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;">
|
||||||
|
<template v-for="timelineClip in timelineTrack.clips">
|
||||||
|
<TimelineClipView :timelineTrack :timelineClip />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<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,96 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { timelineClipColor, toAbsoluteDuration, toAbsoluteTime, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
import { toPx, usePx } from '@/lib/usePx';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { useCssVar, useElementBounding } from '@vueuse/core';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, shallowRef, useTemplateRef } from 'vue';
|
||||||
|
import { getComponentFor } from '.';
|
||||||
|
|
||||||
|
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 * parseInt(outlineSelectedWidth.value, 10) : false);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div @click="selectClip"
|
||||||
|
class="tw:absolute tw:h-full tw:border tw:rounded-(--timeline-clip-border-radius) tw:overflow-hidden" :style="{
|
||||||
|
left,
|
||||||
|
width: width.string,
|
||||||
|
maxWidth: width.string,
|
||||||
|
borderColor: autorepeat ? 'transparent' : 'var(--timeline-clip-border-color)',
|
||||||
|
}">
|
||||||
|
|
||||||
|
<!-- 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,74 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import BottomLine from './BottomLine.vue';
|
||||||
|
import AudioWaveform from './AudioWaveform.vue';
|
||||||
|
|
||||||
|
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,120 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useWaveform } from '@/audio/AudioWaveform';
|
||||||
|
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core';
|
||||||
|
import { shallowRef, useTemplateRef, watchEffect } from 'vue';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const resizeObserver: globalThis.ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => {
|
||||||
|
const c = unrefElement(canvas);
|
||||||
|
if (!c) return;
|
||||||
|
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const entry = entries.filter(entry => entry.target === c)[0];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
// get the size from the ResizeObserverEntry (contentRect) and handle
|
||||||
|
// devicePixelRatio so the canvas looks sharp on HiDPI screens
|
||||||
|
const rect = entry.contentRect || c.getBoundingClientRect();
|
||||||
|
const cssWidth = rect.width;
|
||||||
|
const cssHeight = rect.height;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// set internal canvas size in device pixels
|
||||||
|
c.width = Math.max(1, Math.round(cssWidth * dpr));
|
||||||
|
c.height = Math.max(1, Math.round(cssHeight * dpr));
|
||||||
|
|
||||||
|
canvasWidth.value = c.width;
|
||||||
|
|
||||||
|
redraw(waveform.isDone.value, waveform.peaks.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let peakHeights = new Uint32Array(0);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useResizeObserver(canvas, resizeObserver);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
redraw(waveform.isDone.value, waveform.peaks.value);
|
||||||
|
}, { flush: 'sync' });
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<canvas ref="canvas" class="tw:size-full">
|
||||||
|
</canvas>
|
||||||
|
</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,51 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
import { computed } from '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>
|
||||||
|
<!-- 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,24 @@
|
||||||
|
<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,36 @@
|
||||||
|
<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,45 @@
|
||||||
|
<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,76 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
// import { toPx } from '@/lib/vue';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import Default from './Default.vue';
|
||||||
|
import { computed, reactive, toRefs } from 'vue';
|
||||||
|
|
||||||
|
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 { TimelineClipData, TimelineTrackData } from "@/lib/Timeline";
|
||||||
|
import type { Component } from "vue";
|
||||||
|
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,91 @@
|
||||||
|
<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" class="tick tick-minor" :style="{ left: `${10 * (i < 5 ? i : i + 1)}%` }" />
|
||||||
|
<div v-for="i in 10" class="tick tick-patch" :style="{ left: `${10 * i + 5}%` }" />
|
||||||
|
|
||||||
|
<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,37 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue';
|
||||||
|
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks';
|
||||||
|
import { toPx } from '@/lib/vue';
|
||||||
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import TickInterval from './TickInterval.vue';
|
||||||
|
|
||||||
|
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=""
|
||||||
|
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }">
|
||||||
|
|
||||||
|
<!-- header ticks for seconds and beats-->
|
||||||
|
<div class="tw:absolute tw:size-full" v-for="{ ticks, position } in allTicks">
|
||||||
|
<TickInterval v-for="tick in ticks.ticks.value" :position :left="ticks.left(tick).value"
|
||||||
|
:width="ticks.widthPx.value" :label="ticks.label(tick).value" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tw:absolute tw:size-full tw:border-b tw:border-[#252525]"></div>
|
||||||
|
|
||||||
|
<!-- header markers -->
|
||||||
|
<div class="tw:absolute tw:size-full">
|
||||||
|
<MarkerBox v-for="marker in timeline.markers" :marker />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimelineMarkerData } from '@/lib/Timeline';
|
||||||
|
|
||||||
|
const {
|
||||||
|
timelineMarker,
|
||||||
|
} = defineProps<{
|
||||||
|
timelineMarker: TimelineMarkerData,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const left = '15%';
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tw:absolute tw:w-px tw:h-full" :style="{
|
||||||
|
left,
|
||||||
|
backgroundColor: timelineMarker.color,
|
||||||
|
}" />
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<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';
|
||||||
|
import { computed, shallowRef, useTemplateRef } from 'vue';
|
||||||
|
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,28 @@
|
||||||
|
<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,17 @@
|
||||||
|
<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,21 @@
|
||||||
|
<!-- 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" :left="ticks.left(tick).value" />
|
||||||
|
<MarkerLine v-for="marker in timeline.markers" :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";
|
||||||
|
export { default as HeaderMarkers } from "./HeaderMarkers.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 - Math.pow(-2 * x + 2, 2) / 2;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import mitt, { type Handler } 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,65 @@
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { formatBeats, formatTime } from ".";
|
||||||
|
|
||||||
|
describe("format time", () => {
|
||||||
|
test("baseline", () => {
|
||||||
|
expect(formatTime).toBeDefined();
|
||||||
|
expect(() => formatTime(0)).not.toThrow();
|
||||||
|
});
|
||||||
|
test("default", () => {
|
||||||
|
expect(formatTime(0)).toBe("0:00.000");
|
||||||
|
});
|
||||||
|
test("precision limits", () => {
|
||||||
|
expect(formatTime(0, 0)).toBe("0:00");
|
||||||
|
expect(formatTime(0, 1)).toBe("0:00.0");
|
||||||
|
expect(formatTime(0, 2)).toBe("0:00.00");
|
||||||
|
expect(formatTime(0, 3)).toBe("0:00.000");
|
||||||
|
expect(formatTime(0, 4)).toBe("0:00.000");
|
||||||
|
});
|
||||||
|
test("valid inputs", () => {
|
||||||
|
expect(formatTime(0)).toBe("0:00.000");
|
||||||
|
expect(formatTime(1)).toBe("0:01.000");
|
||||||
|
expect(formatTime(12)).toBe("0:12.000");
|
||||||
|
expect(formatTime(0.001, 3)).toBe("0:00.001");
|
||||||
|
expect(formatTime(0.001, 2)).toBe("0:00.00");
|
||||||
|
expect(formatTime(0.123)).toBe("0:00.123");
|
||||||
|
expect(formatTime(60)).toBe("1:00.000");
|
||||||
|
expect(formatTime(61)).toBe("1:01.000");
|
||||||
|
expect(formatTime(123.456)).toBe("2:03.456");
|
||||||
|
expect(formatTime(-123.456)).toBe("-2:03.456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("format beats", () => {
|
||||||
|
test("baseline", () => {
|
||||||
|
expect(formatBeats).toBeDefined();
|
||||||
|
expect(() => formatBeats(0)).not.toThrow();
|
||||||
|
});
|
||||||
|
test("default", () => {
|
||||||
|
expect(formatBeats(0)).toBe("00.000");
|
||||||
|
});
|
||||||
|
test("precision limits", () => {
|
||||||
|
expect(formatBeats(0, 0)).toBe("00");
|
||||||
|
expect(formatBeats(0, 1)).toBe("00.0");
|
||||||
|
expect(formatBeats(0, 2)).toBe("00.00");
|
||||||
|
expect(formatBeats(0, 3)).toBe("00.000");
|
||||||
|
expect(formatBeats(0, 4)).toBe("00.000");
|
||||||
|
});
|
||||||
|
test("minimum padding", () => {
|
||||||
|
expect(formatBeats(0, 0)).toBe("00");
|
||||||
|
expect(formatBeats(1, 0)).toBe("01");
|
||||||
|
expect(formatBeats(10, 0)).toBe("10");
|
||||||
|
expect(formatBeats(20, 0)).toBe("20");
|
||||||
|
expect(formatBeats(100, 0)).toBe("100");
|
||||||
|
});
|
||||||
|
test("valid inputs", () => {
|
||||||
|
expect(formatBeats(0)).toBe("00.000");
|
||||||
|
expect(formatBeats(1)).toBe("01.000");
|
||||||
|
expect(formatBeats(12)).toBe("12.000");
|
||||||
|
expect(formatBeats(0.001, 3)).toBe("00.001");
|
||||||
|
expect(formatBeats(0.001, 2)).toBe("00.00");
|
||||||
|
expect(formatBeats(0.123)).toBe("00.123");
|
||||||
|
expect(formatBeats(61)).toBe("61.000");
|
||||||
|
expect(formatBeats(123.456)).toBe("123.456");
|
||||||
|
expect(formatBeats(-123.456)).toBe("-123.456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
import type { EasingName } from "@/lib/easing";
|
||||||
|
import { modRange } from "@/lib/math";
|
||||||
|
import type { AnyTime, Beats, Seconds } from "@/lib/units";
|
||||||
|
import { useSetterRef } from "@/lib/vue";
|
||||||
|
import { clamp } from "@vueuse/core";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
|
||||||
|
export const LANGUAGES = [
|
||||||
|
"English",
|
||||||
|
"Russian",
|
||||||
|
"Korean",
|
||||||
|
"Japanese",
|
||||||
|
"Hindi",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export declare type Language = typeof LANGUAGES[number];
|
||||||
|
|
||||||
|
export declare type ColorString = string;
|
||||||
|
|
||||||
|
export declare type AudioType =
|
||||||
|
| "mpeg"
|
||||||
|
| "wav"
|
||||||
|
| "ogg";
|
||||||
|
|
||||||
|
export function ext(audioType: AudioType): string {
|
||||||
|
switch (audioType) {
|
||||||
|
case "mpeg":
|
||||||
|
return "mp3";
|
||||||
|
case "wav":
|
||||||
|
return "wav";
|
||||||
|
case "ogg":
|
||||||
|
return "ogg";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioTrack {
|
||||||
|
// Propreties from JSON
|
||||||
|
Name: string;
|
||||||
|
IsExplicit: boolean;
|
||||||
|
Language: Language;
|
||||||
|
WindUpTimer: Seconds;
|
||||||
|
Bpm: number;
|
||||||
|
Beats: Beats;
|
||||||
|
LoopOffset: Beats;
|
||||||
|
Ext: string;
|
||||||
|
FileDurationIntro: Seconds;
|
||||||
|
FileDurationLoop: Seconds;
|
||||||
|
FileNameIntro: string;
|
||||||
|
FileNameLoop: string;
|
||||||
|
BeatsOffset: Beats;
|
||||||
|
|
||||||
|
FadeOutBeat: Beats;
|
||||||
|
FadeOutDuration: Beats;
|
||||||
|
ColorTransitionIn: Beats;
|
||||||
|
ColorTransitionOut: Beats;
|
||||||
|
ColorTransitionEasing: EasingName;
|
||||||
|
|
||||||
|
FlickerLightsTimeSeries: Beats[];
|
||||||
|
Lyrics: TimeSeries<string>;
|
||||||
|
DrunknessLoopOffsetTimeSeries: TimeSeries<Beats>;
|
||||||
|
CondensationLoopOffsetTimeSeries: TimeSeries<Beats>;
|
||||||
|
|
||||||
|
Palette: ColorString[];
|
||||||
|
GameOverText: string | null;
|
||||||
|
|
||||||
|
// Added from Codenames.json
|
||||||
|
Artist: string;
|
||||||
|
Song: string;
|
||||||
|
|
||||||
|
// Properties added by the TrackStore
|
||||||
|
loadedIntro: AudioBuffer | null;
|
||||||
|
loadedLoop: AudioBuffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dummyAudioTrackForTesting(): AudioTrack {
|
||||||
|
return {
|
||||||
|
Name: "Test Name",
|
||||||
|
IsExplicit: false,
|
||||||
|
Language: "English",
|
||||||
|
WindUpTimer: 35.0,
|
||||||
|
Bpm: 120,
|
||||||
|
Beats: 12,
|
||||||
|
LoopOffset: 8,
|
||||||
|
Ext: "ogg",
|
||||||
|
FileDurationIntro: 35 + 16 + 2,
|
||||||
|
FileDurationLoop: 24.0,
|
||||||
|
FileNameIntro: "TestIntro.ogg",
|
||||||
|
FileNameLoop: "TestLoop.ogg",
|
||||||
|
BeatsOffset: 0,
|
||||||
|
|
||||||
|
FadeOutBeat: NaN,
|
||||||
|
FadeOutDuration: 0,
|
||||||
|
ColorTransitionIn: 0.25,
|
||||||
|
ColorTransitionOut: 0.25,
|
||||||
|
ColorTransitionEasing: "OutExpo",
|
||||||
|
|
||||||
|
FlickerLightsTimeSeries: [],
|
||||||
|
Lyrics: [],
|
||||||
|
DrunknessLoopOffsetTimeSeries: [],
|
||||||
|
CondensationLoopOffsetTimeSeries: [],
|
||||||
|
|
||||||
|
Palette: ["#000000", "#FFFFFF"],
|
||||||
|
GameOverText: "",
|
||||||
|
|
||||||
|
Artist: "Test Artist",
|
||||||
|
Song: "Test Song",
|
||||||
|
|
||||||
|
loadedIntro: null,
|
||||||
|
loadedLoop: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeSeries<T> = [AnyTime, T][];
|
||||||
|
|
||||||
|
export function timeSeriesIsEmpty(timeSeries: TimeSeries<AnyTime>): boolean {
|
||||||
|
return timeSeries.length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Codenames {
|
||||||
|
[key: string]: {
|
||||||
|
Artist: string;
|
||||||
|
Song: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timeline wall clock timestamp like [-]0:00.000 with configurable milliseconds precision.
|
||||||
|
*/
|
||||||
|
export function formatTime(time: Seconds, precision: number = 3): string {
|
||||||
|
const isNegative = time < 0;
|
||||||
|
const isNegativeString = isNegative ? "-" : "";
|
||||||
|
if (isNegative) {
|
||||||
|
time = -time;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
const secondsString = seconds.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
precision = clamp(precision, 0, 3);
|
||||||
|
if (precision === 0) {
|
||||||
|
return `${isNegativeString}${minutes}:${secondsString}`;
|
||||||
|
}
|
||||||
|
const factor = 10 ** precision;
|
||||||
|
const subsecond = Math.floor((time * factor) % factor);
|
||||||
|
const subsecondString = subsecond.toString().padStart(precision, "0");
|
||||||
|
return `${isNegativeString}${minutes}:${secondsString}.${subsecondString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timeline beats timestamp like [-]00.000 with configurable precision for fractional part.
|
||||||
|
*/
|
||||||
|
export function formatBeats(beats: Beats, precision: number = 3): string {
|
||||||
|
const isNegative = beats < 0;
|
||||||
|
const isNegativeString = isNegative ? "-" : "";
|
||||||
|
if (isNegative) {
|
||||||
|
beats = -beats;
|
||||||
|
}
|
||||||
|
const integer = Math.floor(beats);
|
||||||
|
const integerString = integer.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
precision = clamp(precision, 0, 3);
|
||||||
|
if (precision === 0) {
|
||||||
|
return `${isNegativeString}${integerString}`;
|
||||||
|
}
|
||||||
|
const factor = 10 ** precision;
|
||||||
|
const fractional = Math.floor((beats % 1) * factor);
|
||||||
|
const fractionalString = fractional.toString().padStart(precision, "0");
|
||||||
|
return `${isNegativeString}${integerString}.${fractionalString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsToBeats(track: AudioTrack, seconds: Seconds): Beats {
|
||||||
|
const percent = seconds / track.FileDurationLoop;
|
||||||
|
return percent * track.Beats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beatsToSeconds(track: AudioTrack, beats: Beats): Seconds {
|
||||||
|
const percent = beats / track.Beats;
|
||||||
|
return percent * track.FileDurationLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duration of LoopOffset beats converted to seconds. */
|
||||||
|
export function loopOffsetSeconds(track: AudioTrack): Seconds {
|
||||||
|
return beatsToSeconds(track, track.LoopOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duration of Wind-up Timer plus Loop Offset combined and converted to seconds. */
|
||||||
|
export function introWithLoopOffsetDurationSeconds(track: AudioTrack): Seconds {
|
||||||
|
const { WindUpTimer } = track;
|
||||||
|
|
||||||
|
return WindUpTimer + loopOffsetSeconds(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duration of Wind-up Timer plus Loop Offset plus one full loop combined and converted to seconds. */
|
||||||
|
export function totalDurationSeconds(track: AudioTrack): Seconds {
|
||||||
|
const { FileDurationLoop } = track;
|
||||||
|
|
||||||
|
return introWithLoopOffsetDurationSeconds(track) + FileDurationLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapTimeFn(track: AudioTrack): (time: Seconds) => Seconds {
|
||||||
|
const startOfLoop = introWithLoopOffsetDurationSeconds(track);
|
||||||
|
const endOfLoop = totalDurationSeconds(track);
|
||||||
|
return (time: Seconds): Seconds => {
|
||||||
|
return Math.max(0, modRange(time, startOfLoop, endOfLoop));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapTime(track: AudioTrack, time: Seconds): Seconds {
|
||||||
|
return wrapTimeFn(track)(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWrapTime(
|
||||||
|
track: AudioTrack,
|
||||||
|
initialTime: Seconds,
|
||||||
|
): Ref<Seconds> {
|
||||||
|
const wrapper = wrapTimeFn(track);
|
||||||
|
return useSetterRef<Seconds>(initialTime, wrapper);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
import {
|
||||||
|
type AudioTrack,
|
||||||
|
beatsToSeconds,
|
||||||
|
type ColorString,
|
||||||
|
loopOffsetSeconds,
|
||||||
|
secondsToBeats,
|
||||||
|
} from "@/lib/AudioTrack";
|
||||||
|
import { namedVars as clipColorNamedVars } from "@/lib/colors/clips";
|
||||||
|
import { namedVars as markerColorNamedVars } from "@/lib/colors/markers";
|
||||||
|
import { iterWindowPairs } from "./iter";
|
||||||
|
import type { Beats } from "./units";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference point for all clips on the timeline track.
|
||||||
|
*
|
||||||
|
* - "absolute" means relative to start of the intro, measured in seconds.
|
||||||
|
* - "wind-up" is relative to the WindUpTimer, when Jester pops up, measured in beats.
|
||||||
|
* - "loop" is relative to the WindUpTimer plus loop offset, measured in beats.
|
||||||
|
*/
|
||||||
|
export type Reference = "absolute" | "wind-up" | "loop";
|
||||||
|
|
||||||
|
/** Clip views support dynamic tailored content view for each track type. */
|
||||||
|
export type ContentViewType =
|
||||||
|
| "audio"
|
||||||
|
/** One-shot event at clipIn */
|
||||||
|
| "event"
|
||||||
|
| "fadeout"
|
||||||
|
| "palette"
|
||||||
|
| "text"
|
||||||
|
/** Interpolated line between points in time series. */
|
||||||
|
| "curve";
|
||||||
|
|
||||||
|
export interface MuzikaGromcheTimelineTracksMap {
|
||||||
|
intro: TimelineTrackData;
|
||||||
|
loop: TimelineTrackData;
|
||||||
|
flickering: TimelineTrackData;
|
||||||
|
fadeOut: TimelineTrackData;
|
||||||
|
palette: TimelineTrackData;
|
||||||
|
lyrics: TimelineTrackData;
|
||||||
|
drunkness: TimelineTrackData;
|
||||||
|
condensation: TimelineTrackData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineTracksArray(
|
||||||
|
self: MuzikaGromcheTimelineTracksMap,
|
||||||
|
): TimelineTrackData[] {
|
||||||
|
return [
|
||||||
|
self.intro,
|
||||||
|
self.loop,
|
||||||
|
self.flickering,
|
||||||
|
self.fadeOut,
|
||||||
|
self.palette,
|
||||||
|
self.lyrics,
|
||||||
|
self.drunkness,
|
||||||
|
self.condensation,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTimelineTracksMap(): MuzikaGromcheTimelineTracksMap {
|
||||||
|
return {
|
||||||
|
intro: {
|
||||||
|
name: "Intro",
|
||||||
|
color: clipColorNamedVars.lime,
|
||||||
|
reference: "absolute",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "audio",
|
||||||
|
},
|
||||||
|
loop: {
|
||||||
|
name: "Loop",
|
||||||
|
color: clipColorNamedVars.blue,
|
||||||
|
reference: "absolute",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "audio",
|
||||||
|
},
|
||||||
|
flickering: {
|
||||||
|
name: "Flickering",
|
||||||
|
color: clipColorNamedVars.violet,
|
||||||
|
reference: "loop",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "event",
|
||||||
|
},
|
||||||
|
fadeOut: {
|
||||||
|
name: "Fade out",
|
||||||
|
color: clipColorNamedVars.chocolate,
|
||||||
|
reference: "loop",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "fadeout",
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
name: "Palette",
|
||||||
|
color: clipColorNamedVars.pink,
|
||||||
|
reference: "wind-up",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "palette",
|
||||||
|
},
|
||||||
|
lyrics: {
|
||||||
|
name: "Lyrics",
|
||||||
|
color: clipColorNamedVars.tan,
|
||||||
|
reference: "loop",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "text",
|
||||||
|
},
|
||||||
|
drunkness: {
|
||||||
|
name: "Drunkness",
|
||||||
|
color: clipColorNamedVars.orange,
|
||||||
|
reference: "loop",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "curve",
|
||||||
|
},
|
||||||
|
condensation: {
|
||||||
|
name: "Condensation",
|
||||||
|
color: clipColorNamedVars.yellow,
|
||||||
|
reference: "loop",
|
||||||
|
clips: [],
|
||||||
|
contentViewType: "curve",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateClips(
|
||||||
|
track: AudioTrack,
|
||||||
|
): MuzikaGromcheTimelineTracksMap {
|
||||||
|
const tracks = emptyTimelineTracksMap();
|
||||||
|
|
||||||
|
if (!track.loadedIntro || !track.loadedLoop) return tracks;
|
||||||
|
|
||||||
|
tracks.intro.clips.push({
|
||||||
|
clipIn: 0,
|
||||||
|
duration: track.FileDurationIntro,
|
||||||
|
audioBuffer: track.loadedIntro,
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let clipIn = track.FileDurationIntro;
|
||||||
|
tracks.loop.clips.push(
|
||||||
|
{
|
||||||
|
clipIn,
|
||||||
|
duration: track.FileDurationLoop,
|
||||||
|
audioBuffer: track.loadedLoop,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (let i = 1; i < 10; i++) {
|
||||||
|
let clipIn2 = clipIn + track.FileDurationLoop * i;
|
||||||
|
tracks.loop.clips.push(
|
||||||
|
{
|
||||||
|
clipIn: clipIn2,
|
||||||
|
duration: track.FileDurationLoop,
|
||||||
|
autorepeat: true,
|
||||||
|
audioBuffer: track.loadedLoop,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracks.fadeOut.clips.push(
|
||||||
|
{ clipIn: track.FadeOutBeat, duration: track.FadeOutDuration },
|
||||||
|
);
|
||||||
|
// TODO: palette, lyrics, both VFX
|
||||||
|
{
|
||||||
|
for (const time of track.FlickerLightsTimeSeries) {
|
||||||
|
tracks.flickering.clips.push(
|
||||||
|
{ clipIn: time, duration: 1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// TODO: offset by?
|
||||||
|
// track.ColorTransitionIn
|
||||||
|
for (let i = 0; i < track.Palette.length; i++) {
|
||||||
|
const color = track.Palette[i];
|
||||||
|
tracks.palette.clips.push(
|
||||||
|
{ clipIn: i, duration: 1, color, name: color },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
for (const [[time, text], next] of iterWindowPairs(track.Lyrics)) {
|
||||||
|
let duration = 4;
|
||||||
|
if (next !== undefined) {
|
||||||
|
// make sure adjacent clips don't overlap
|
||||||
|
const [nextTime, _nextText] = next;
|
||||||
|
duration = Math.min(duration, nextTime - time);
|
||||||
|
}
|
||||||
|
tracks.lyrics.clips.push(
|
||||||
|
{ clipIn: time, duration, name: text },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineTrackData {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
reference: Reference;
|
||||||
|
clips: TimelineClipData[];
|
||||||
|
contentViewType: ContentViewType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineClipData {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
clipIn: number;
|
||||||
|
duration: number;
|
||||||
|
autorepeat?: boolean;
|
||||||
|
/** Represented audio buffer, for track.contentViewType === "audio" only */
|
||||||
|
audioBuffer?: AudioBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineClipAutorepeat(self: TimelineClipData): boolean {
|
||||||
|
return self.autorepeat ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineClipOut(self: TimelineClipData) {
|
||||||
|
return self.clipIn + self.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineClipColor(
|
||||||
|
track: TimelineTrackData,
|
||||||
|
clip: TimelineClipData,
|
||||||
|
): ColorString {
|
||||||
|
return clip.color ?? track.color ?? clipColorNamedVars.green;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineClipLabel(
|
||||||
|
track: TimelineTrackData,
|
||||||
|
clip: TimelineClipData,
|
||||||
|
): ColorString {
|
||||||
|
return clip.name ?? track.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineMarkerData {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
reference: Reference;
|
||||||
|
markerIn: number;
|
||||||
|
/**
|
||||||
|
* Originally intended to be a separation between beats and seconds references.
|
||||||
|
* But since most of the events are in beats coordinate space, let's repurpose it to be marker-defined.
|
||||||
|
*
|
||||||
|
* Defaults to "bottom".
|
||||||
|
*/
|
||||||
|
position?: "top" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMarkers(track: AudioTrack): TimelineMarkerData[] {
|
||||||
|
const markers: TimelineMarkerData[] = [];
|
||||||
|
|
||||||
|
if (track.LoopOffset === 0) {
|
||||||
|
markers.push({
|
||||||
|
name: "Wind-up Timer & Loop Offset",
|
||||||
|
color: markerColorNamedVars.lavender,
|
||||||
|
reference: "wind-up",
|
||||||
|
markerIn: 0,
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
markers.push({
|
||||||
|
name: "Wind-up Timer",
|
||||||
|
color: markerColorNamedVars.lavender,
|
||||||
|
reference: "wind-up",
|
||||||
|
markerIn: 0,
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
markers.push({
|
||||||
|
name: "Loop Offset",
|
||||||
|
color: markerColorNamedVars.fuchsia,
|
||||||
|
reference: "loop",
|
||||||
|
markerIn: 0,
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
markers.push({
|
||||||
|
name: "End of Loop",
|
||||||
|
color: markerColorNamedVars.purple,
|
||||||
|
reference: "loop",
|
||||||
|
markerIn: track.Beats,
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
|
||||||
|
const reservedLoopOffsetBeats: Beats[] = [
|
||||||
|
-track.LoopOffset,
|
||||||
|
0,
|
||||||
|
track.Beats,
|
||||||
|
];
|
||||||
|
|
||||||
|
const firstBeat = Math.ceil(-secondsToBeats(track, track.WindUpTimer)) -
|
||||||
|
track.LoopOffset;
|
||||||
|
|
||||||
|
// TODO: i from absolute zero, not wind-up zero
|
||||||
|
for (let i = firstBeat; i < track.Beats; i++) {
|
||||||
|
if (reservedLoopOffsetBeats.includes(i)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i % 4 === 0) {
|
||||||
|
// marker on strong beat
|
||||||
|
markers.push({
|
||||||
|
name: `Bar (${i})`,
|
||||||
|
color: markerColorNamedVars.blue,
|
||||||
|
reference: "loop",
|
||||||
|
markerIn: i,
|
||||||
|
position: "bottom",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// regular marker on other beats
|
||||||
|
if (false) {
|
||||||
|
markers.push({
|
||||||
|
name: "Beat",
|
||||||
|
color: markerColorNamedVars.cyan,
|
||||||
|
reference: "loop",
|
||||||
|
markerIn: i,
|
||||||
|
position: "bottom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAbsoluteDuration(
|
||||||
|
track: AudioTrack | null,
|
||||||
|
reference: Reference,
|
||||||
|
time: number,
|
||||||
|
): number {
|
||||||
|
if (track === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
switch (reference) {
|
||||||
|
default:
|
||||||
|
case "absolute":
|
||||||
|
return time;
|
||||||
|
case "wind-up":
|
||||||
|
case "loop":
|
||||||
|
return beatsToSeconds(track, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAbsoluteTime(
|
||||||
|
track: AudioTrack | null,
|
||||||
|
reference: Reference,
|
||||||
|
time: number,
|
||||||
|
): number {
|
||||||
|
if (track === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
switch (reference) {
|
||||||
|
default:
|
||||||
|
case "absolute":
|
||||||
|
return time;
|
||||||
|
case "wind-up":
|
||||||
|
return beatsToSeconds(track, time) + track.WindUpTimer;
|
||||||
|
case "loop":
|
||||||
|
return beatsToSeconds(track, time) + track.WindUpTimer +
|
||||||
|
loopOffsetSeconds(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markerToAbsoluteTime(
|
||||||
|
track: AudioTrack,
|
||||||
|
marker: TimelineMarkerData,
|
||||||
|
): number {
|
||||||
|
return toAbsoluteTime(track, marker.reference, marker.markerIn);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { AnyTime, Beats, Px, Seconds } from "@/lib/units";
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
type ComputedRef,
|
||||||
|
type MaybeRefOrGetter,
|
||||||
|
shallowReadonly,
|
||||||
|
type ShallowRef,
|
||||||
|
shallowRef,
|
||||||
|
toValue,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
const TICK_INTERVAL_PX_THRESHOLD = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find such time interval that converted to pixels it won't exceed this threshold.
|
||||||
|
* Start with large intervals, and progressively subdivide it until a suitably small interval is found.
|
||||||
|
* @param width timeline width (including empty space) in pixels
|
||||||
|
* @param duration timeline visual duration (including empty space) in seconds
|
||||||
|
* @returns Visually optimal interval for ticks, in seconds.
|
||||||
|
*/
|
||||||
|
export function findOptimalTickInterval(
|
||||||
|
width: Px,
|
||||||
|
duration: Seconds,
|
||||||
|
): Seconds {
|
||||||
|
const pxPerSec = Number.isFinite(duration) && duration > 0
|
||||||
|
? width / duration
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
// If we can't compute a sensible pixels-per-second, fall back to the smallest interval.
|
||||||
|
if (!Number.isFinite(pxPerSec)) return 1;
|
||||||
|
|
||||||
|
const seconds = TICK_INTERVAL_PX_THRESHOLD / pxPerSec;
|
||||||
|
if (seconds >= 2) {
|
||||||
|
return Math.floor(seconds / 2) * 2;
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptimalTickInterval(
|
||||||
|
width: MaybeRefOrGetter<Px>,
|
||||||
|
duration: MaybeRefOrGetter<Seconds>,
|
||||||
|
): ComputedRef<Seconds> {
|
||||||
|
return computed(() =>
|
||||||
|
findOptimalTickInterval(toValue(width), toValue(duration))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find such beats interval that converted to pixels it won't exceed this threshold.
|
||||||
|
* @param width timeline width (including empty space) in pixels
|
||||||
|
* @param duration timeline visual duration (including empty space) in beats
|
||||||
|
* @returns Visually optimal interval for ticks, in beats.
|
||||||
|
*/
|
||||||
|
export function findOptimalBeatTickInterval(
|
||||||
|
width: Px,
|
||||||
|
duration: Beats,
|
||||||
|
): Beats {
|
||||||
|
const pxPerBeat = Number.isFinite(duration) && duration > 0
|
||||||
|
? width / duration
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
// If we can't compute a sensible pixels-per-beat, fall back to the smallest interval.
|
||||||
|
if (!Number.isFinite(pxPerBeat)) return 1;
|
||||||
|
|
||||||
|
const beats = TICK_INTERVAL_PX_THRESHOLD / pxPerBeat;
|
||||||
|
if (beats >= 4) {
|
||||||
|
return Math.floor(beats / 4) * 4;
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptimalBeatTickInterval(
|
||||||
|
width: MaybeRefOrGetter<Px>,
|
||||||
|
duration: MaybeRefOrGetter<Beats>,
|
||||||
|
): ComputedRef<Beats> {
|
||||||
|
return computed(() =>
|
||||||
|
findOptimalTickInterval(toValue(width), toValue(duration))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicksBounds<T extends AnyTime> {
|
||||||
|
tickIn: T;
|
||||||
|
tickOut: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find closest bounds for ticks divisible by `interval` such that they cover the whole viewport.
|
||||||
|
* TickIn is the largest value <= viewportIn that is a multiple of `interval`.
|
||||||
|
* TickOut is the smallest value >= viewportOut that is a multiple of `interval`.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - `interval` is expected to be a positive finite number. If it's invalid, the original viewport bounds are returned.
|
||||||
|
* - Works with fractional intervals and negative times.
|
||||||
|
*
|
||||||
|
* @param interval tick spacing (seconds or beats)
|
||||||
|
* @param viewportIn left/earlier bound of the viewport (seconds or beats)
|
||||||
|
* @param viewportOut right/later bound of the viewport (seconds or beats)
|
||||||
|
* @returns object with { tickIn, tickOut }
|
||||||
|
*/
|
||||||
|
export function findTicksBounds<T extends AnyTime>(
|
||||||
|
interval: T,
|
||||||
|
viewportIn: T,
|
||||||
|
viewportOut: T,
|
||||||
|
): TicksBounds<T> {
|
||||||
|
if (!Number.isFinite(interval) || interval <= 0) {
|
||||||
|
return { tickIn: viewportIn, tickOut: viewportOut };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize -0 to 0 for cleanliness
|
||||||
|
const normalize = (v: T) => (Object.is(v, -0) ? 0 as T : v);
|
||||||
|
|
||||||
|
const tickIn = normalize(Math.floor(viewportIn / interval) * interval as T);
|
||||||
|
const tickOut = normalize(Math.ceil(viewportOut / interval) * interval as T);
|
||||||
|
|
||||||
|
return { tickIn, tickOut };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicksBounds<T extends AnyTime>(
|
||||||
|
interval: MaybeRefOrGetter<T>,
|
||||||
|
viewportIn: MaybeRefOrGetter<T>,
|
||||||
|
viewportOut: MaybeRefOrGetter<T>,
|
||||||
|
): Readonly<ShallowRef<TicksBounds<T>>> {
|
||||||
|
// only trigger when really needed.
|
||||||
|
const ticksBounds = shallowRef<TicksBounds<T>>({
|
||||||
|
tickIn: toValue(viewportIn),
|
||||||
|
tickOut: toValue(viewportOut),
|
||||||
|
});
|
||||||
|
watch([interval, viewportIn, viewportOut], () => {
|
||||||
|
const bounds = findTicksBounds(
|
||||||
|
toValue(interval),
|
||||||
|
toValue(viewportIn),
|
||||||
|
toValue(viewportOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
bounds.tickIn !== ticksBounds.value.tickIn ||
|
||||||
|
bounds.tickOut !== ticksBounds.value.tickOut
|
||||||
|
) {
|
||||||
|
ticksBounds.value = bounds;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
return shallowReadonly(ticksBounds);
|
||||||
|
}
|
||||||