forked from nikita/muzika-gromche
WIP: Add frontend web app player & editor in Vue 3 + Vite
TODO: - implement viewing & editing. - Add links to deployment, and CHANGELOG. - Change web app icon.
This commit is contained in:
parent
a74bbfaee2
commit
aa489eb305
|
|
@ -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/muzika-gromche/` to root@ratijas.me `/var/www/html/muzika-gromche/`.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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,48 @@
|
|||
{
|
||||
"name": "muzika-gromche-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "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.16",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"@vitest/coverage-v8": "4.0.10",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "~9.39.1",
|
||||
"eslint-plugin-vue": "~10.5.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@7.1.14",
|
||||
"vite-plugin-vue-devtools": "^8.0.3",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vitest": "^4.0.10",
|
||||
"vitest-browser-vue": "^2.0.1",
|
||||
"vue-tsc": "^3.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.1.14"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"core-js"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
Binary file not shown.
|
After Width: | Height: | Size: 426 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
export const VOLUME_MAX: number = 1.5;
|
||||
|
||||
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>();
|
||||
|
||||
// currently playing nodes
|
||||
currentIntro: AudioBufferSourceNode | null = null;
|
||||
currentLoop: AudioBufferSourceNode | null = null;
|
||||
|
||||
introDuration: number | undefined = undefined;
|
||||
loopDuration: number | undefined = undefined;
|
||||
|
||||
// timing bookkeeping
|
||||
introStartTime: number | undefined = undefined; // audioCtx.currentTime when intro started
|
||||
playedDuration: number | undefined = undefined; // seconds elapsed at pause
|
||||
stoppingTimeoutID: number | undefined = undefined; // running setTimeout for fade out and cleanup
|
||||
shuttingDown: boolean = false;
|
||||
|
||||
// settings
|
||||
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
|
||||
|
||||
init() {
|
||||
if (this.shuttingDown) return;
|
||||
if (this.audioCtx) return;
|
||||
this.audioCtx =
|
||||
new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
|
||||
this.masterGain = this.audioCtx.createGain();
|
||||
this.fadeGain = this.audioCtx.createGain();
|
||||
|
||||
// routing: sources -> fadeGain -> masterGain -> destination
|
||||
this.fadeGain.connect(this.masterGain);
|
||||
this.masterGain.connect(this.audioCtx.destination);
|
||||
|
||||
// default full volume
|
||||
this.masterGain.gain.value = 1;
|
||||
this.fadeGain.gain.value = 1;
|
||||
|
||||
this.currentIntro = null;
|
||||
this.currentLoop = null;
|
||||
|
||||
this.introStartTime = undefined;
|
||||
this.playedDuration = undefined;
|
||||
|
||||
if (this.stoppingTimeoutID !== undefined) {
|
||||
clearTimeout(this.stoppingTimeoutID);
|
||||
this.stoppingTimeoutID = undefined;
|
||||
}
|
||||
if (this.shuttingDown) {
|
||||
this.shuttingDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.pause({ shutdown: true });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private fadeOutNow(fade = this.fadeDuration) {
|
||||
if (!this.audioCtx || !this.fadeGain) return;
|
||||
const now = this.audioCtx.currentTime;
|
||||
const end = now + fade;
|
||||
this.fadeGain.gain.cancelScheduledValues(now);
|
||||
this.fadeGain.gain.setValueAtTime(this.fadeGain.gain.value, now);
|
||||
this.fadeGain.gain.linearRampToValueAtTime(0.0001, end);
|
||||
}
|
||||
|
||||
private fadeInNow(fade = this.fadeDuration) {
|
||||
if (!this.audioCtx || !this.fadeGain) return;
|
||||
const now = this.audioCtx.currentTime;
|
||||
const end = now + fade;
|
||||
this.fadeGain.gain.cancelScheduledValues(now);
|
||||
this.fadeGain.gain.setValueAtTime(0.0001, now);
|
||||
this.fadeGain.gain.linearRampToValueAtTime(1, end);
|
||||
}
|
||||
|
||||
// Play intro then seamlessly transition to loop (loop=true).
|
||||
// Normally tracks have an intro buffer and a loop buffer. If the requested
|
||||
// start offset falls after the intro duration, we skip playing the intro
|
||||
// and start the loop immediately with a calculated offset into the loop.
|
||||
// offset = seconds into the composite timeline (intro + loop) where playback should start
|
||||
playBuffers(introBuffer: AudioBuffer, loopBuffer: AudioBuffer, position = 0) {
|
||||
if (this.shuttingDown) return;
|
||||
this.init();
|
||||
if (!this.audioCtx || !this.fadeGain) return;
|
||||
|
||||
// stop any previous nodes
|
||||
this.stopNodes();
|
||||
// abort the clean up after fade out which would've stopped our newly created nodes
|
||||
clearTimeout(this.stoppingTimeoutID);
|
||||
this.stoppingTimeoutID = undefined;
|
||||
|
||||
const now = this.audioCtx.currentTime;
|
||||
|
||||
this.introDuration = introBuffer.duration;
|
||||
this.loopDuration = loopBuffer.duration;
|
||||
|
||||
// figure out where to start
|
||||
if (position < this.introDuration) {
|
||||
// start intro with offset, schedule loop after remaining intro time
|
||||
const introOffset = position;
|
||||
const timeUntilLoop = this.introDuration - introOffset;
|
||||
|
||||
const introNode = this.audioCtx.createBufferSource();
|
||||
introNode.buffer = introBuffer;
|
||||
introNode.connect(this.fadeGain);
|
||||
introNode.start(now, introOffset);
|
||||
|
||||
const loopNode = this.audioCtx.createBufferSource();
|
||||
loopNode.buffer = loopBuffer;
|
||||
loopNode.loop = true;
|
||||
loopNode.connect(this.fadeGain);
|
||||
loopNode.start(now + timeUntilLoop, 0);
|
||||
|
||||
this.currentIntro = introNode;
|
||||
this.currentLoop = loopNode;
|
||||
this.introStartTime = now - position;
|
||||
} else {
|
||||
// start directly in loop with proper offset into loop
|
||||
const loopOffset = (position - this.introDuration) % this.loopDuration;
|
||||
const loopNode = this.audioCtx.createBufferSource();
|
||||
loopNode.buffer = loopBuffer;
|
||||
loopNode.loop = true;
|
||||
loopNode.connect(this.fadeGain!);
|
||||
loopNode.start(now, loopOffset);
|
||||
|
||||
this.currentIntro = null;
|
||||
this.currentLoop = loopNode;
|
||||
this.introStartTime = now - this.introDuration - loopOffset;
|
||||
}
|
||||
|
||||
// brief fade to avoid clicks
|
||||
this.fadeInNow();
|
||||
this.playedDuration = undefined;
|
||||
}
|
||||
|
||||
pause({ shutdown = false }: { shutdown?: boolean } = {}) {
|
||||
if (!this.audioCtx) return;
|
||||
if (this.shuttingDown) return;
|
||||
this.shuttingDown = shutdown;
|
||||
// capture played duration at stop time before the fade
|
||||
// (the fade is just an effect which does not affect core timing logic)
|
||||
this.playedDuration = this.getPosition();
|
||||
this.introStartTime = undefined;
|
||||
// fade quickly then stop
|
||||
this.fadeOutNow();
|
||||
// schedule stop slightly after fade to avoid cutting off
|
||||
const stopAfterMs = (this.fadeDuration + 0.005) * 1000;
|
||||
// clean up after the fade, but leave a hatch to abort the clean up
|
||||
this.stoppingTimeoutID = setTimeout(() => {
|
||||
this.stopNodes();
|
||||
if (this.shuttingDown) {
|
||||
this.stopEngine();
|
||||
}
|
||||
}, stopAfterMs);
|
||||
}
|
||||
|
||||
private stopNodes() {
|
||||
try {
|
||||
this.currentIntro?.stop();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
this.currentLoop?.stop();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
this.currentIntro = null;
|
||||
this.currentLoop = null;
|
||||
}
|
||||
|
||||
private stopEngine() {
|
||||
this.audioCtx?.close();
|
||||
this.audioCtx = null;
|
||||
this.fadeGain = null;
|
||||
this.masterGain = null;
|
||||
this.shuttingDown = false;
|
||||
}
|
||||
|
||||
// Return playback position in seconds relative to intro start (intro=0..introDuration, then loop)
|
||||
getPosition(): number {
|
||||
if (!this.audioCtx) return 0;
|
||||
if (this.introStartTime === undefined) {
|
||||
// paused, position stays constant
|
||||
return this.playedDuration ?? 0;
|
||||
} else {
|
||||
// make sure playback position stays in bounds: wrap around loop duration
|
||||
const now = this.audioCtx.currentTime;
|
||||
let playedDuration = now - this.introStartTime;
|
||||
if (
|
||||
this.introDuration !== undefined && this.loopDuration !== undefined &&
|
||||
playedDuration > this.introDuration + this.loopDuration
|
||||
) {
|
||||
playedDuration = this.introDuration + (playedDuration - this.introDuration) % this.loopDuration;
|
||||
}
|
||||
return Math.max(0, now - this.introStartTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const audioEngine = new AudioEngine();
|
||||
export default audioEngine;
|
||||
|
|
@ -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,56 @@
|
|||
<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>
|
||||
.input-text {
|
||||
background-color: var(--input-background-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
outline: var(--input-border-width) solid var(--input-outline-color);
|
||||
}
|
||||
|
||||
.input-text:has(input:focus) {
|
||||
outline-color: var(--input-outline-selected-color);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
color: #929292;
|
||||
transition: color 150ms linear;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
color: #767676;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts" setup>
|
||||
import { useTrackStore } from '@/store/TrackStore';
|
||||
|
||||
const trackStore = useTrackStore();
|
||||
</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: {{ trackStore.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,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 ref="scrollContainer"
|
||||
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,92 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import classes from './ToolBar.module.css';
|
||||
|
||||
|
||||
const {
|
||||
orientation = "horizontal",
|
||||
defaultValue = undefined,
|
||||
} = defineProps<{
|
||||
orientation?: "horizontal" | "vertical",
|
||||
defaultValue?: number,
|
||||
}>();
|
||||
|
||||
const isVertical = computed(() => orientation === 'vertical');
|
||||
|
||||
const model = defineModel<number>();
|
||||
|
||||
function reset(event: MouseEvent) {
|
||||
if (defaultValue !== undefined) {
|
||||
event.preventDefault();
|
||||
model.value = defaultValue;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<input type="range" v-model.number="model" :orient="isVertical ? 'vertical' : null"
|
||||
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']"
|
||||
@dblclick="reset" />
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-runnable-track,
|
||||
.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,
|
||||
.slider[orient="vertical"]::-moz-range-track {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb,
|
||||
.slider::-moz-range-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(#919191 80%, #212121);
|
||||
}
|
||||
|
||||
.slider:active::-webkit-slider-thumb,
|
||||
.slider:active::-moz-range-thumb {
|
||||
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
|
||||
}
|
||||
|
||||
.slider:focus-visible::-webkit-slider-thumb,
|
||||
.slider:focus-visible::-moz-range-thumb {
|
||||
outline: 4px solid #556cc9;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
@import "tailwindcss" prefix(tw);
|
||||
|
||||
@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 tw:flex-none tw:w-12 tw:h-12 tw:rounded-full;
|
||||
|
||||
@variant hover {
|
||||
&:not(:disabled) {
|
||||
@apply tw:text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&>svg {
|
||||
fill: currentColor;
|
||||
@apply tw:w-12 tw:h-12;
|
||||
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
|
||||
}
|
||||
}
|
||||
|
||||
.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 tw:flex-none tw:w-4 tw:h-4 tw:rounded-full;
|
||||
|
||||
@variant hover {
|
||||
&:not(:disabled) {
|
||||
color: #b9b9ba;
|
||||
background-color: #303034;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&>svg {
|
||||
fill: currentColor;
|
||||
@apply tw:w-4 tw: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,81 @@
|
|||
<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);
|
||||
},
|
||||
});
|
||||
|
||||
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" :defaultValue title="Volume"
|
||||
list="markers" />
|
||||
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
|
||||
<!-- list="markers" -->
|
||||
<datalist id="markers">
|
||||
<option :value="defaultValue"></option>
|
||||
<!--
|
||||
<option value="0"></option>
|
||||
<option value="4"></option>
|
||||
<option value="8"></option>
|
||||
<option value="12"></option>
|
||||
<option value="16"></option>
|
||||
-->
|
||||
</datalist>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts">
|
||||
import Slider from '@/components/library/Slider.vue';
|
||||
import Add from "@material-design-icons/svg/filled/add.svg";
|
||||
import Remove from "@material-design-icons/svg/filled/remove.svg";
|
||||
import { clamp } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
import ToolButtonSmall from './ToolButtonSmall.vue';
|
||||
|
||||
const {
|
||||
orientation = "horizontal",
|
||||
defaultZoom = undefined,
|
||||
extended = false,
|
||||
} = defineProps<{
|
||||
orientation?: "horizontal" | "vertical",
|
||||
defaultZoom?: number,
|
||||
extended?: boolean,
|
||||
}>();
|
||||
|
||||
/** Zoom factor from 1 (fit content) to about 10 (content takes up 10x more space than the viewport).
|
||||
* If extended is set, lower bound will be less than 1 (about 0.5) and upper bound is about 20x.
|
||||
*/
|
||||
const zoom = defineModel<number>("zoom", { required: true });
|
||||
|
||||
const zoomStepButtons = 10;
|
||||
const zoomStepSlider = 1;
|
||||
/* 0..100 or if extended is set -20..100 */
|
||||
const zoomMin = computed(() => extended ? -2 * zoomStepButtons : 0);
|
||||
const zoomMax = computed(() => extended ? 200 : 100);
|
||||
|
||||
const scale = 16;
|
||||
// dirty hack because no exp growth: after extended threshold scale is more steep
|
||||
const scale2 = 8;
|
||||
const scaleThreshold = 100;
|
||||
const zoomThreshold = 1 + scaleThreshold / scale;
|
||||
|
||||
// zoom: external level 0.5 .. 7.25
|
||||
// slider value: interval 0 .. 100 or -20 .. 100
|
||||
function toSliderValue(zoom: number): number {
|
||||
if (zoom > zoomThreshold) {
|
||||
return toSliderValue(zoomThreshold) + (zoom - zoomThreshold) * scale2;
|
||||
}
|
||||
if (zoom >= 1) {
|
||||
return (zoom - 1) * scale;
|
||||
} else {
|
||||
return -2 * zoomMin.value * (zoom - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function fromSliderValue(value: number): number {
|
||||
// dirty hack because no exp growth
|
||||
if (value > scaleThreshold) {
|
||||
return fromSliderValue(scaleThreshold) + (value - scaleThreshold) / scale2;
|
||||
}
|
||||
if (value >= 0) {
|
||||
return 1 + value / scale;
|
||||
} else {
|
||||
return 1 - value / (2 * zoomMin.value);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultValue = computed(() => defaultZoom === undefined ? undefined : toSliderValue(defaultZoom));
|
||||
|
||||
// Internal integer representation that avoids floating point errors.
|
||||
const zoomSliderValue = computed<number>({
|
||||
get() {
|
||||
return Math.round(toSliderValue(zoom.value));
|
||||
},
|
||||
set(value) {
|
||||
value = clamp(Math.round(value), zoomMin.value, zoomMax.value);
|
||||
zoom.value = fromSliderValue(value);
|
||||
},
|
||||
});
|
||||
|
||||
function onButton(direction: number): void {
|
||||
let val = zoomSliderValue.value - zoomMin.value;
|
||||
if (val % zoomStepButtons !== 0) {
|
||||
// go to the nearest full step up or down depending on the direction
|
||||
val = ((direction > 0) ? Math.ceil : Math.floor)(val / zoomStepButtons);
|
||||
zoomSliderValue.value = val * zoomStepButtons + zoomMin.value;
|
||||
} else {
|
||||
zoomSliderValue.value += direction * zoomStepButtons;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- for some reason min-width does not propagate up from Slider -->
|
||||
<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="onButton(-1)" :disabled="zoomSliderValue <= zoomMin" />
|
||||
<Slider :min="zoomMin" :max="zoomMax" :step="zoomStepSlider" v-model.number="zoomSliderValue" :orientation
|
||||
:defaultValue />
|
||||
<ToolButtonSmall :icon="Add" title="Zoom In" @click="onButton(+1)" :disabled="zoomSliderValue >= zoomMax" />
|
||||
</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,55 @@
|
|||
<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; */
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
<script setup lang="ts">
|
||||
import Playhead from '@/components/timeline/Playhead.vue';
|
||||
// import Timestamp from '@/components/timeline/Timestamp.vue';
|
||||
// import { dummyAudioTrackForTesting, secondsToBeats } from '@/lib/AudioTrack';
|
||||
import ZoomSlider from '@/components/library/ZoomSlider.vue';
|
||||
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
|
||||
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
|
||||
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
|
||||
import { useOptionalWidgetState, type UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
|
||||
import { bindTwoWay, toPx } from '@/lib/vue';
|
||||
import { DEFAULT_ZOOM_HORIZONTAL, DEFAULT_ZOOM_VERTICAL, useTimelineStore } from '@/store/TimelineStore';
|
||||
import { useTrackStore } from '@/store/TrackStore';
|
||||
import { useElementBounding, useEventListener, useScroll } from '@vueuse/core';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, 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 trackStore = useTrackStore();
|
||||
const timeline = useTimelineStore();
|
||||
|
||||
const audioTrack = computed(() => trackStore.currentAudioTrack!);
|
||||
|
||||
const {
|
||||
headerHeight, sidebarWidth,
|
||||
viewportZoomHorizontal, viewportZoomVertical,
|
||||
viewportScrollOffsetTop, viewportScrollOffsetLeft,
|
||||
} = storeToRefs(timeline);
|
||||
|
||||
// const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 3));
|
||||
const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 10));
|
||||
|
||||
// const playbackPositionSeconds = defineModel<number | null>('playbackPositionSeconds', { default: null });
|
||||
// const playbackPositionBeats = computed<number | null>(() => {
|
||||
// if (playbackPositionSeconds.value === null) {
|
||||
// return null;
|
||||
// }
|
||||
// return secondsToBeats(audioTrack.value!, playbackPositionSeconds.value);
|
||||
// });
|
||||
// const playbackPosition = computed<number>(() => {
|
||||
// if (playbackPositionSeconds.value === null) {
|
||||
// return 0;
|
||||
// }
|
||||
// return playbackPositionSeconds.value / timelineTotalDurationSeconds.value;
|
||||
// });
|
||||
//
|
||||
// const cursorPositionSeconds = shallowRef<number | null>(0)
|
||||
// const cursorPositionBeats = computed<number | null>(() => {
|
||||
// if (cursorPositionSeconds.value === null) {
|
||||
// return null;
|
||||
// }
|
||||
// return secondsToBeats(audioTrack.value!, cursorPositionSeconds.value);
|
||||
// });
|
||||
// const cursorPosition = computed<number>(() => {
|
||||
// if (cursorPositionSeconds.value === null) {
|
||||
// return 0;
|
||||
// }
|
||||
// return cursorPositionSeconds.value / timelineTotalDurationSeconds.value;
|
||||
// });
|
||||
|
||||
// const timelineEl = useTemplateRef('timeline');
|
||||
//
|
||||
// function _cursorToPositionSeconds(e: MouseEvent): number | null {
|
||||
// if (audioTrack.value === null || timelineEl.value === null) {
|
||||
// return null;
|
||||
// }
|
||||
// // clientX is broken in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=505521#c80
|
||||
// const x = e.pageX - timelineEl.value.offsetLeft;
|
||||
// const rect = timelineEl.value.getBoundingClientRect();
|
||||
// const position = x / rect.width;
|
||||
// const positionClamped = Math.max(0, Math.min(1, position));
|
||||
// return positionClamped * timelineTotalDurationSeconds.value;
|
||||
// }
|
||||
//
|
||||
// const isDragging = shallowRef(false);
|
||||
//
|
||||
// function timelinePointerDown(event: PointerEvent) {
|
||||
// const tl = timelineEl.value;
|
||||
// if (tl && !isDragging.value) {
|
||||
// isDragging.value = true;
|
||||
// tl.setPointerCapture(event.pointerId);
|
||||
// timelinePointerMove(event);
|
||||
// }
|
||||
// }
|
||||
// function timelinePointerUp(event: PointerEvent) {
|
||||
// timelinePointerMove(event);
|
||||
// if (isDragging.value) {
|
||||
// isDragging.value = false;
|
||||
// }
|
||||
// }
|
||||
// function timelinePointerMove(event: PointerEvent) {
|
||||
// // preview mouse position
|
||||
// console.log("MOVE", cursorPositionSeconds.value);
|
||||
// cursorPositionSeconds.value = _cursorToPositionSeconds(event);
|
||||
// if (isDragging.value) {
|
||||
// // apply mouse position
|
||||
// playbackPositionSeconds.value = cursorPositionSeconds.value;
|
||||
// }
|
||||
// }
|
||||
// function timelinePointerLeave(_event: PointerEvent) {
|
||||
// console.log("LEAVE", isDragging.value);
|
||||
// if (!isDragging.value) {
|
||||
// cursorPositionSeconds.value = null;
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// 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 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);
|
||||
|
||||
function scrollZoomHandler(event: WheelEvent) {
|
||||
// Note: Math.random() prevents console output history from collapsing same entries.
|
||||
// console.log("WHEEEEEL", Math.random().toFixed(3), event.deltaX, event.deltaY, event.target, event);
|
||||
|
||||
// TODO: Ignore Ctrl key because it intercepts touchpad pinch to zoom?
|
||||
// TODO: what if the user doesn't use a touchpad, and thus has
|
||||
// no way to scroll horizontally other than by dragging a scrollbar?
|
||||
const ignoreCtrlWheel = false;
|
||||
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
viewportZoomVertical.value -= event.deltaY / 100;
|
||||
}
|
||||
else if (event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
viewportZoomHorizontal.value -= event.deltaY / 100;
|
||||
}
|
||||
else if (event.ctrlKey && !ignoreCtrlWheel) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
viewportScrollOffsetLeft.value += event.deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(timelineRootElement, "wheel", scrollZoomHandler, { passive: false, });
|
||||
|
||||
// Shift+Z - reset zoom
|
||||
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
|
||||
timeline.zoomToggleBetweenWholeAndLoop();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
</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`,
|
||||
}" style="border-top: var(--view-separator-border);">
|
||||
|
||||
<!-- 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 v-model:zoom="viewportZoomHorizontal" :default-zoom="DEFAULT_ZOOM_HORIZONTAL" extended
|
||||
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);">
|
||||
|
||||
<TimelineHeader />
|
||||
|
||||
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
|
||||
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
||||
<!-- </Playhead> -->
|
||||
|
||||
</ScrollSync>
|
||||
|
||||
<!-- TODO -->
|
||||
<!-- <div ref="timeline" class="timeline" @pointerdown="timelinePointerDown" @pointerup="timelinePointerUp"
|
||||
@pointermove="timelinePointerMove" @pointerleave="timelinePointerLeave"> -->
|
||||
|
||||
<!-- 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.contentWidthPx }">
|
||||
|
||||
<!-- 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 v-model:zoom="viewportZoomVertical" orientation="vertical" :default-zoom="DEFAULT_ZOOM_VERTICAL"
|
||||
class="tw:w-full tw:min-h-0" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* .timeline {
|
||||
background-color: var(--timeline-background-color);
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
overflow: hidden;
|
||||
} */
|
||||
|
||||
.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,80 @@
|
|||
<script setup lang="ts">
|
||||
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 { useTrackStore } from '@/store/TrackStore';
|
||||
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';
|
||||
|
||||
const trackStore = useTrackStore();
|
||||
const timeline = useTimelineStore();
|
||||
const { currentAudioTrack, isPlaying } = storeToRefs(trackStore);
|
||||
|
||||
const hasLoopOffset = computed(() => currentAudioTrack.value?.LoopOffset !== 0);
|
||||
|
||||
const rightSidebar = useOptionalWidgetState({
|
||||
visible: useLocalStorage("timeline.rightSidebar.visible", true),
|
||||
showString: "Show Right Sidebar",
|
||||
hideString: "Hide Right Sidebar",
|
||||
width: 32,
|
||||
});
|
||||
|
||||
function rewindToIntro() {
|
||||
trackStore.rewindToIntro();
|
||||
syncPlayheadPosition();
|
||||
}
|
||||
function rewindToWindUp() {
|
||||
trackStore.rewindToWindUp();
|
||||
syncPlayheadPosition();
|
||||
}
|
||||
function rewindToLoop() {
|
||||
trackStore.rewindToLoop();
|
||||
syncPlayheadPosition();
|
||||
}
|
||||
function syncPlayheadPosition() {
|
||||
timeline.playheadPosition = trackStore.playedDuration;
|
||||
timeline.ensurePlayheadWithinViewport();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="tw:flex tw:flex-col toolbar-background" style="border-top: var(--view-separator-border);">
|
||||
<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:max-sm:border-b-(--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="trackStore.togglePlayPause()" />
|
||||
<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.playheadPositionBeats" />
|
||||
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
|
||||
{{ currentAudioTrack?.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>
|
||||
<Timeline class="tw:flex-1 tw:min-h-0" :rightSidebar />
|
||||
</div>
|
||||
</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,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,42 @@
|
|||
<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';
|
||||
|
||||
const {
|
||||
width,
|
||||
} = defineProps<{
|
||||
track: TimelineTrackData,
|
||||
clip: TimelineClipData,
|
||||
width: number,
|
||||
}>();
|
||||
|
||||
// const { trackHeight } = storeToRefs(useTimelineStore());
|
||||
// const color = "#00000080";
|
||||
</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,10 @@
|
|||
/**
|
||||
* Content view components for different TimelineClipView implementations.
|
||||
* @module components/timeline/clip/impl
|
||||
*/
|
||||
|
||||
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 { 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 Default;
|
||||
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-1 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,29 @@
|
|||
<script setup lang="ts">
|
||||
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:relative tw:max-h-full tw:overflow-hidden" style="border-bottom: 1px solid #252525;"
|
||||
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }">
|
||||
|
||||
<!-- header ticks for seconds and beats-->
|
||||
<div class="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>
|
||||
</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,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineMarkerData } from '@/lib/Timeline';
|
||||
|
||||
const {
|
||||
marker,
|
||||
} = defineProps<{
|
||||
marker: TimelineMarkerData,
|
||||
}>();
|
||||
|
||||
const left = `15%`;
|
||||
</script>
|
||||
<template>
|
||||
<div class="tw:absolute tw:w-0 tw:h-full tw:border-l" :style="{
|
||||
left,
|
||||
borderColor: marker.color,
|
||||
}" :label="marker.name" />
|
||||
</template>
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimelineMarkerData } from '@/lib/Timeline';
|
||||
|
||||
const {
|
||||
marker,
|
||||
} = defineProps<{
|
||||
marker: TimelineMarkerData,
|
||||
}>();
|
||||
|
||||
const left = `${15 + 15 * marker.markerIn}px`;
|
||||
</script>
|
||||
<template>
|
||||
<div class="tw:absolute tw:w-0 tw:h-full tw:border-l" :style="{
|
||||
left,
|
||||
borderColor: marker.color,
|
||||
}" :label="marker.name" />
|
||||
</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,32 @@
|
|||
<!-- Thin colored vertical lines stretching across the timeline, below the clips -->
|
||||
<script setup lang="ts">
|
||||
import type { TimelineMarkerData } from "@/lib/Timeline";
|
||||
import { useTimelineTicksBeats } from '@/lib/useTimelineTicks';
|
||||
import MarkerLine from "./MarkerLine.vue";
|
||||
import TickLine from "./TickLine.vue";
|
||||
|
||||
const ticks = useTimelineTicksBeats();
|
||||
|
||||
ticks.ticks.value.map(tick => {
|
||||
tick
|
||||
})
|
||||
|
||||
const marker: TimelineMarkerData = {
|
||||
name: "0",
|
||||
color: "var(--timeline-marker-beat-color)",
|
||||
reference: "loop",
|
||||
markerIn: 0,
|
||||
};
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<MarkerLine :marker />
|
||||
<MarkerLine :marker="{ ...marker, name: '1', markerIn: 1 }" />
|
||||
</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,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,175 @@
|
|||
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: number;
|
||||
Bpm: number;
|
||||
Beats: number;
|
||||
LoopOffset: number;
|
||||
Ext: string;
|
||||
FileDurationIntro: number;
|
||||
FileDurationLoop: number;
|
||||
FileNameIntro: string;
|
||||
FileNameLoop: string;
|
||||
BeatsOffset: number;
|
||||
|
||||
FadeOutBeat: number;
|
||||
FadeOutDuration: number;
|
||||
ColorTransitionIn: number;
|
||||
ColorTransitionOut: number;
|
||||
ColorTransitionEasing: string;
|
||||
|
||||
FlickerLightsTimeSeries: number[];
|
||||
Lyrics: TimeSeries<string>;
|
||||
DrunknessLoopOffsetTimeSeries: TimeSeries<number>;
|
||||
CondensationLoopOffsetTimeSeries: TimeSeries<number>;
|
||||
|
||||
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> = [number, T][];
|
||||
|
||||
export function timeSeriesIsEmpty(timeSeries: TimeSeries<any>): boolean {
|
||||
return timeSeries.length !== 0;
|
||||
}
|
||||
|
||||
export interface Codenames {
|
||||
[key: string]: {
|
||||
Artist: string;
|
||||
Song: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTime(time: number, 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 milliseconds = Math.floor((time * 1000) % 1000);
|
||||
const secondsString = seconds.toString().padStart(2, "0");
|
||||
const millisecondsString = milliseconds.toString().padStart(precision, "0");
|
||||
return `${isNegativeString}${minutes}:${secondsString}.${millisecondsString}`;
|
||||
}
|
||||
|
||||
export function formatBeats(beats: number, precision: number = 3): string {
|
||||
const isNegative = beats < 0;
|
||||
const isNegativeString = isNegative ? "-" : "";
|
||||
if (isNegative) {
|
||||
beats = -beats;
|
||||
}
|
||||
const integer = Math.floor(beats);
|
||||
const fractional = Math.floor((beats % 1) * 1000);
|
||||
const integerString = integer.toString().padEnd(2, "0");
|
||||
const fractionalString = fractional.toString().padStart(precision, "0");
|
||||
return `${isNegativeString}${integerString}.${fractionalString}`;
|
||||
}
|
||||
|
||||
export function secondsToBeats(track: AudioTrack, seconds: number): number {
|
||||
const percent = seconds / track.FileDurationLoop;
|
||||
return percent * track.Beats;
|
||||
}
|
||||
|
||||
export function beatsToSeconds(track: AudioTrack, beats: number): number {
|
||||
const percent = beats / track.Beats;
|
||||
return percent * track.FileDurationLoop;
|
||||
}
|
||||
|
||||
/** Duration of LoopOffset beats converted to seconds. */
|
||||
export function loopOffsetSeconds(track: AudioTrack): number {
|
||||
return beatsToSeconds(track, track.LoopOffset);
|
||||
}
|
||||
|
||||
/** Duration of Wind-up Timer plus Loop Offset combined and converted to seconds. */
|
||||
export function introWithLoopOffsetDurationSeconds(track: AudioTrack): number {
|
||||
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) {
|
||||
const { FileDurationLoop } = track;
|
||||
|
||||
return introWithLoopOffsetDurationSeconds(track) + FileDurationLoop;
|
||||
}
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
import {
|
||||
type AudioTrack,
|
||||
beatsToSeconds,
|
||||
type ColorString,
|
||||
loopOffsetSeconds,
|
||||
} from "@/lib/AudioTrack";
|
||||
import * as namedColors from "@/lib/colors/named-vars";
|
||||
import { green } from "./colors/named-vars";
|
||||
import { iterWindowPairs } from "./iter";
|
||||
|
||||
/**
|
||||
* 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: namedColors.lime,
|
||||
reference: "absolute",
|
||||
clips: [],
|
||||
contentViewType: "audio",
|
||||
},
|
||||
loop: {
|
||||
name: "Loop",
|
||||
color: namedColors.blue,
|
||||
reference: "absolute",
|
||||
clips: [],
|
||||
contentViewType: "audio",
|
||||
},
|
||||
flickering: {
|
||||
name: "Flickering",
|
||||
color: namedColors.violet,
|
||||
reference: "loop",
|
||||
clips: [],
|
||||
contentViewType: "event",
|
||||
},
|
||||
fadeOut: {
|
||||
name: "Fade out",
|
||||
color: namedColors.chocolate,
|
||||
reference: "loop",
|
||||
clips: [],
|
||||
contentViewType: "fadeout",
|
||||
},
|
||||
palette: {
|
||||
name: "Palette",
|
||||
color: namedColors.pink,
|
||||
reference: "wind-up",
|
||||
clips: [],
|
||||
contentViewType: "palette",
|
||||
},
|
||||
lyrics: {
|
||||
name: "Lyrics",
|
||||
color: namedColors.tan,
|
||||
reference: "loop",
|
||||
clips: [],
|
||||
contentViewType: "text",
|
||||
},
|
||||
drunkness: {
|
||||
name: "Drunkness",
|
||||
color: namedColors.orange,
|
||||
reference: "loop",
|
||||
clips: [],
|
||||
contentViewType: "curve",
|
||||
},
|
||||
condensation: {
|
||||
name: "Condensation",
|
||||
color: namedColors.yellow,
|
||||
reference: "loop",
|
||||
clips: [],
|
||||
contentViewType: "curve",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function generateClips(
|
||||
track: AudioTrack,
|
||||
): MuzikaGromcheTimelineTracksMap {
|
||||
const tracks = emptyTimelineTracksMap();
|
||||
|
||||
tracks.intro.clips.push({ clipIn: 0, duration: track.FileDurationIntro });
|
||||
{
|
||||
let clipIn = track.FileDurationIntro;
|
||||
tracks.loop.clips.push(
|
||||
{ clipIn, duration: track.FileDurationLoop },
|
||||
);
|
||||
for (let i = 1; i < 10; i++) {
|
||||
let clipIn2 = clipIn + track.FileDurationLoop * i;
|
||||
tracks.loop.clips.push(
|
||||
{ clipIn: clipIn2, duration: track.FileDurationLoop, autorepeat: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 ?? 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;
|
||||
}
|
||||
|
||||
export function generateMarkers(track: AudioTrack): TimelineMarkerData[] {
|
||||
const markers = [];
|
||||
|
||||
if (track.LoopOffset === 0) {
|
||||
markers.push({
|
||||
name: "Wind-up Timer & Loop Offset",
|
||||
color: namedColors.purple,
|
||||
reference: "wind-up",
|
||||
markerIn: 0,
|
||||
});
|
||||
} else {
|
||||
markers.push({
|
||||
name: "Wind-up Timer",
|
||||
color: namedColors.purple,
|
||||
reference: "wind-up",
|
||||
markerIn: 0,
|
||||
});
|
||||
markers.push({
|
||||
name: "Loop Offset",
|
||||
color: namedColors.violet,
|
||||
reference: "loop",
|
||||
markerIn: 0,
|
||||
});
|
||||
}
|
||||
markers.push({
|
||||
name: "End of Loop",
|
||||
color: namedColors.purple,
|
||||
reference: "loop",
|
||||
markerIn: track.Beats,
|
||||
});
|
||||
|
||||
// TODO: i from absolute zero, not wind-up zero
|
||||
for (let i = 1; i < track.Beats; i++) {
|
||||
if (i % 4 === 0) {
|
||||
// marker on strong beat
|
||||
markers.push({
|
||||
name: "Bar",
|
||||
color: namedColors.blue,
|
||||
reference: "loop",
|
||||
markerIn: i,
|
||||
});
|
||||
} else {
|
||||
// regular marker on other beats
|
||||
markers.push({
|
||||
name: "Beat",
|
||||
color: namedColors.teal,
|
||||
reference: "loop",
|
||||
markerIn: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export default [
|
||||
"#eb6e01",
|
||||
"#ffa833",
|
||||
"#d4ad1f",
|
||||
"#9fc615",
|
||||
"#5f9921",
|
||||
"#448f65",
|
||||
"#019899",
|
||||
"#005278",
|
||||
"#4376a1",
|
||||
"#9972a0",
|
||||
"#d0568d",
|
||||
"#e98cb5",
|
||||
"#b9af97",
|
||||
"#c4a07c",
|
||||
"#996601",
|
||||
"#8c5a3f",
|
||||
] as const;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export default [
|
||||
"var(--timeline-clip-color-orange)",
|
||||
"var(--timeline-clip-color-apricot)",
|
||||
"var(--timeline-clip-color-yellow)",
|
||||
"var(--timeline-clip-color-lime)",
|
||||
"var(--timeline-clip-color-olive)",
|
||||
"var(--timeline-clip-color-green)",
|
||||
"var(--timeline-clip-color-teal)",
|
||||
"var(--timeline-clip-color-navy)",
|
||||
"var(--timeline-clip-color-blue)",
|
||||
"var(--timeline-clip-color-purple)",
|
||||
"var(--timeline-clip-color-violet)",
|
||||
"var(--timeline-clip-color-pink)",
|
||||
"var(--timeline-clip-color-tan)",
|
||||
"var(--timeline-clip-color-beige)",
|
||||
"var(--timeline-clip-color-brown)",
|
||||
"var(--timeline-clip-color-chocolate)",
|
||||
] as const;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export const orange = "#eb6e01";
|
||||
export const apricot = "#ffa833";
|
||||
export const yellow = "#d4ad1f";
|
||||
export const lime = "#9fc615";
|
||||
export const olive = "#5f9921";
|
||||
export const green = "#448f65";
|
||||
export const teal = "#019899";
|
||||
export const navy = "#005278";
|
||||
export const blue = "#4376a1";
|
||||
export const purple = "#9972a0";
|
||||
export const violet = "#d0568d";
|
||||
export const pink = "#e98cb5";
|
||||
export const tan = "#b9af97";
|
||||
export const beige = "#c4a07c";
|
||||
export const brown = "#996601";
|
||||
export const chocolate = "#8c5a3f";
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export const orange = "var(--timeline-clip-color-orange)";
|
||||
export const apricot = "var(--timeline-clip-color-apricot)";
|
||||
export const yellow = "var(--timeline-clip-color-yellow)";
|
||||
export const lime = "var(--timeline-clip-color-lime)";
|
||||
export const olive = "var(--timeline-clip-color-olive)";
|
||||
export const green = "var(--timeline-clip-color-green)";
|
||||
export const teal = "var(--timeline-clip-color-teal)";
|
||||
export const navy = "var(--timeline-clip-color-navy)";
|
||||
export const blue = "var(--timeline-clip-color-blue)";
|
||||
export const purple = "var(--timeline-clip-color-purple)";
|
||||
export const violet = "var(--timeline-clip-color-violet)";
|
||||
export const pink = "var(--timeline-clip-color-pink)";
|
||||
export const tan = "var(--timeline-clip-color-tan)";
|
||||
export const beige = "var(--timeline-clip-color-beige)";
|
||||
export const brown = "var(--timeline-clip-color-brown)";
|
||||
export const chocolate = "var(--timeline-clip-color-chocolate)";
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
export interface FirstLast<T> {
|
||||
isFirst: boolean,
|
||||
isLast: boolean,
|
||||
value: T,
|
||||
}
|
||||
|
||||
export function *iterFirstLast<T>(it: T[]): Iterable<FirstLast<T>> {
|
||||
for (let i = 0; i < it.length; i++) {
|
||||
yield {
|
||||
isFirst: i === 0,
|
||||
isLast: i === it.length - 1,
|
||||
value: it[i]!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterate over current & next pairs, with next item being undefined at the end of input array. */
|
||||
export function *iterWindowPairs<T>(it: T[]): Iterable<[T, T?]> {
|
||||
for (let i = 0; i < it.length; i++) {
|
||||
yield [it[i]!, it[i + 1]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an inclusive range of numbers from min to max using the given step.
|
||||
* Step must be non-zero; supports positive or negative steps.
|
||||
*/
|
||||
export function rangeInclusive(min: number, max: number, step: number): number[] {
|
||||
if (step === 0) throw new RangeError("step must not be 0");
|
||||
const array: number[] = [];
|
||||
const forward = min <= max;
|
||||
|
||||
if (forward && step < 0) return array;
|
||||
if (!forward && step > 0) return array;
|
||||
|
||||
if (forward) {
|
||||
for (let v = min; v <= max; v += step) {
|
||||
array.push(v);
|
||||
}
|
||||
} else {
|
||||
for (let v = min; v >= max; v += step) {
|
||||
array.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import { modRange } from './math.js'
|
||||
|
||||
describe('modRange', () => {
|
||||
test('1 in range 0..2 is still 1', () => {
|
||||
expect(modRange(1, 0, 2)).toBe(1);
|
||||
});
|
||||
|
||||
test('lower bound is preserved', () => {
|
||||
expect(modRange(1, 1, 2)).toBe(1);
|
||||
});
|
||||
|
||||
test('higher bound is wrapped', () => {
|
||||
expect(modRange(2, 1, 2)).toBe(1);
|
||||
});
|
||||
|
||||
test('bad bounds', () => {
|
||||
expect(() => modRange(0, 9, 1)).toThrow('modRange: min must be less than max (got: min=9, max=1)');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Wraps any number @n greater or equal to @max into the range @min .. @max using modulo (%) operator.
|
||||
* @param {Number} n The number to wrap
|
||||
* @param {Number} min Lower bound of the range
|
||||
* @param {Number} max Higher bound of the range
|
||||
* @return {Number} @n if @n is less then
|
||||
*/
|
||||
export function modRange(n: number, min: number, max: number): number {
|
||||
if (min > max) {
|
||||
throw new Error(`${modRange.name}: min must be less than max (got: min=${min}, max=${max})`);
|
||||
}
|
||||
if (n >= max) {
|
||||
n = min + (n - min) % (max - min);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { onInputKeyStroke } from "@/lib/onInputKeyStroke";
|
||||
|
||||
describe("onInputKeyStroke", () => {
|
||||
it("ignores printable key strokes (Shift+A) when an input is focused", () => {
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const handler = vi.fn();
|
||||
const stop = onInputKeyStroke(
|
||||
(e) => e.shiftKey && (e.key === "A" || e.key === "a"),
|
||||
handler,
|
||||
);
|
||||
|
||||
// dispatch a Shift+A keydown (printable)
|
||||
const ev = new KeyboardEvent("keydown", {
|
||||
key: "A",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
input.blur();
|
||||
// dispatch again, this time input field isn't focused
|
||||
const ev2 = new KeyboardEvent("keydown", {
|
||||
key: "A",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(ev2);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("handles non-printable key strokes (Ctrl+Shift+U) even when input is focused", () => {
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const handler = vi.fn();
|
||||
const stop = onInputKeyStroke(
|
||||
(e) => e.ctrlKey && e.shiftKey && (e.key === "U" || e.key === "u"),
|
||||
handler,
|
||||
);
|
||||
|
||||
// dispatch Ctrl+Shift+U (non-printable because ctrlKey is true)
|
||||
const ev = new KeyboardEvent("keydown", {
|
||||
key: "U",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
type KeyPredicate,
|
||||
onKeyStroke,
|
||||
type OnKeyStrokeOptions,
|
||||
} from "@vueuse/core";
|
||||
|
||||
export const DATA_DISABLE_SHORTCUTS = "data-disable-shortcuts";
|
||||
const DATA_DISABLE_SHORTCUTS_SELECTOR = `[${DATA_DISABLE_SHORTCUTS}]`;
|
||||
|
||||
const textLikeTypes = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"url",
|
||||
"tel",
|
||||
"password",
|
||||
"number",
|
||||
"datetime-local",
|
||||
"date",
|
||||
"time",
|
||||
"month",
|
||||
"week",
|
||||
]);
|
||||
|
||||
function isEditableElement(el?: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const elm = el as HTMLElement;
|
||||
const tag = elm.tagName;
|
||||
if (elm.isContentEditable) return true;
|
||||
// Treat TEXTAREA as editable
|
||||
if (tag === "TEXTAREA") return true;
|
||||
// For INPUT, only consider text-like input types as editable. This
|
||||
// excludes sliders, checkboxes, radio buttons, buttons, file inputs, etc.
|
||||
if (tag === "INPUT") {
|
||||
const input = elm as HTMLInputElement;
|
||||
// If no type attribute is present it defaults to 'text'
|
||||
const type = (input.type || "text").toLowerCase();
|
||||
if (textLikeTypes.has(type)) return true;
|
||||
}
|
||||
// ARIA text-like roles
|
||||
if (
|
||||
elm.getAttribute &&
|
||||
(elm.getAttribute("role") === "searchbox" ||
|
||||
elm.getAttribute("role") === "textbox")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// allow opting out by setting this attribute on a container
|
||||
if (elm.closest && !!elm.closest(DATA_DISABLE_SHORTCUTS_SELECTOR)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around `onKeyStroke` that ignores key strokes when focus is in an
|
||||
* editable element (input/textarea/contenteditable) or when IME composition
|
||||
* is active. Signature mirrors `onKeyStroke(predicate, handler, options?)`.
|
||||
*/
|
||||
export function onInputKeyStroke(
|
||||
predicate: KeyPredicate,
|
||||
handler: (event: KeyboardEvent) => void,
|
||||
options: OnKeyStrokeOptions = { target: window, dedupe: true },
|
||||
) {
|
||||
return onKeyStroke(
|
||||
(e: KeyboardEvent) => {
|
||||
// first, respect the user's predicate
|
||||
if (!predicate(e)) return false;
|
||||
|
||||
// ignore during IME composition
|
||||
if (e.isComposing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if focus is in editable element, do not run shortcut
|
||||
const active = document.activeElement as Element | null;
|
||||
if (isEditableElement(active)) {
|
||||
// allow non-printable shortcuts (Ctrl/Cmd/Alt combos) even when an
|
||||
// input is focused. Printable characters (single-char keys without
|
||||
// modifiers) should be ignored so typing isn't interrupted.
|
||||
const isPrintable = e.key.length === 1 && !e.ctrlKey && !e.metaKey &&
|
||||
!e.altKey;
|
||||
if (isPrintable) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
handler,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export default onInputKeyStroke;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/** Async sleep promise, useful for debugging fast-paced transitions. */
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export type Seconds = number;
|
||||
export type Beats = number;
|
||||
export type AnyTime = Seconds | Beats;
|
||||
|
||||
export type Px = number;
|
||||
export type PxString = `${Px}px`;
|
||||
|
||||
export type ZoomRaw = number;
|
||||
export type ZoomDiscrete = number;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { Px } from "@/lib/units";
|
||||
import { computed, type ComputedRef, type MaybeRef, type Ref, toRef } from "vue";
|
||||
import { type UseWritablePx, useWritablePx } from "./usePx";
|
||||
|
||||
export interface UseOptionalWidgetStateOptions {
|
||||
visible?: MaybeRef<boolean>;
|
||||
showString?: string;
|
||||
hideString?: string;
|
||||
width?: MaybeRef<Px>;
|
||||
height?: MaybeRef<Px>;
|
||||
}
|
||||
|
||||
export interface UseOptionalWidgetStateReturn {
|
||||
visible: Ref<boolean>;
|
||||
toggle: () => void;
|
||||
toggleActionString: ComputedRef<string>;
|
||||
width: UseWritablePx;
|
||||
height: UseWritablePx;
|
||||
}
|
||||
|
||||
export function useOptionalWidgetState(
|
||||
options: UseOptionalWidgetStateOptions = {},
|
||||
): UseOptionalWidgetStateReturn {
|
||||
const {
|
||||
visible: initialVisible = false,
|
||||
showString = "Show",
|
||||
hideString = "Hide",
|
||||
width: initialWidth = 0,
|
||||
height: initialHeight = 0,
|
||||
} = options;
|
||||
|
||||
const visible = toRef(initialVisible);
|
||||
function toggle() {
|
||||
visible.value = !visible.value;
|
||||
}
|
||||
const toggleActionString = computed(() => visible.value ? hideString : showString);
|
||||
const width = useWritablePx(initialWidth);
|
||||
const height = useWritablePx(initialHeight);
|
||||
|
||||
return {
|
||||
visible,
|
||||
toggle,
|
||||
toggleActionString,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { toReactive } from "@vueuse/core";
|
||||
import { describe, expect, expectTypeOf, test } from "vitest";
|
||||
import { computed, ref, shallowRef, toRefs } from "vue";
|
||||
import { toPx, usePx, useWritablePx, type UseWritablePx } from ".";
|
||||
|
||||
describe("usePx", () => {
|
||||
test("toPx should work", () => {
|
||||
// baseline
|
||||
expect(toPx(42)).toBe("42px");
|
||||
// negative
|
||||
expect(toPx(-5)).toBe("-5px");
|
||||
// Vue reactivity
|
||||
expect(toPx(ref(10))).toBe("10px");
|
||||
});
|
||||
test("baseline", () => {
|
||||
const px = usePx(42);
|
||||
expect(px.value.number).toBe(42);
|
||||
expect(px.value.string).toBe("42px");
|
||||
});
|
||||
test("reactivity", () => {
|
||||
const source = shallowRef(1);
|
||||
const px = usePx(source);
|
||||
expect(px.value.number).toBe(1);
|
||||
expect(px.value.string).toBe("1px");
|
||||
source.value = 2;
|
||||
expect(px.value.number).toBe(2);
|
||||
expect(px.value.string).toBe("2px");
|
||||
});
|
||||
test("destructuring toReactive", () => {
|
||||
const source = shallowRef(1);
|
||||
// toReactive
|
||||
const { number, string } = toReactive(usePx(source));
|
||||
expect(number).toBe(1);
|
||||
source.value = 2;
|
||||
// meh
|
||||
expect(number).toBe(1);
|
||||
});
|
||||
test("destructuring toRefs", () => {
|
||||
const source = shallowRef(1);
|
||||
// toRefs, doesn't make sense
|
||||
const { number, string } = toRefs(usePx(source).value);
|
||||
expect(number.value).toBe(1);
|
||||
source.value = 2;
|
||||
// meh
|
||||
expect(number.value).toBe(1);
|
||||
});
|
||||
test("destructuring done right", () => {
|
||||
const source = shallowRef(1);
|
||||
const px = usePx(source);
|
||||
const number = computed(() => px.value.number);
|
||||
expect(number.value).toBe(1);
|
||||
source.value = 2;
|
||||
expect(number.value).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWritablePx", () => {
|
||||
test("type check", () => {
|
||||
expectTypeOf<UseWritablePx>(useWritablePx(0));
|
||||
});
|
||||
test("should work by value", () => {
|
||||
const pxByValue = useWritablePx(42);
|
||||
expect(pxByValue.number.value).toBe(42);
|
||||
expect(pxByValue.string.value).toBe("42px");
|
||||
|
||||
});
|
||||
test("should work by ref", () => {
|
||||
const myRef = shallowRef(42);
|
||||
const pxByRef = useWritablePx(myRef);
|
||||
expect(pxByRef.number.value).toBe(42);
|
||||
expect(pxByRef.string.value).toBe("42px");
|
||||
});
|
||||
test("destructuring", () => {
|
||||
const { number, string } = useWritablePx(1);
|
||||
expect(number.value).toBe(1);
|
||||
expect(string.value).toBe("1px");
|
||||
number.value = 2;
|
||||
expect(number.value).toBe(2);
|
||||
expect(string.value).toBe("2px");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import type { Px, PxString } from "@/lib/units";
|
||||
import type {
|
||||
ComputedRef,
|
||||
MaybeRef,
|
||||
MaybeRefOrGetter,
|
||||
Ref
|
||||
} from "vue";
|
||||
import { computed, toRef, toValue } from "vue";
|
||||
|
||||
function toPxValue(value: Px): PxString {
|
||||
return `${value}px`;
|
||||
}
|
||||
|
||||
export function toPx(value: MaybeRefOrGetter<Px>): PxString {
|
||||
return toPxValue(toValue(value));
|
||||
}
|
||||
|
||||
export interface UsePx {
|
||||
number: Px;
|
||||
string: PxString;
|
||||
}
|
||||
|
||||
export function usePx(value: MaybeRefOrGetter<Px>): ComputedRef<UsePx> {
|
||||
return computed(() => {
|
||||
const number = toValue(value);
|
||||
const string = toPxValue(number);
|
||||
return {
|
||||
number,
|
||||
string,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface UseWritablePx {
|
||||
number: Ref<Px>;
|
||||
string: ComputedRef<PxString>;
|
||||
}
|
||||
|
||||
export function useWritablePx(value: MaybeRef<Px>): UseWritablePx {
|
||||
const number = toRef(value);
|
||||
const string = computed(() => toPxValue(number.value));
|
||||
return {
|
||||
number,
|
||||
string,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { formatTime } from "@/lib/AudioTrack";
|
||||
import { rangeInclusive } from "@/lib/iter";
|
||||
import {
|
||||
type AnyTime,
|
||||
type Beats,
|
||||
type Pixels,
|
||||
type Seconds,
|
||||
useOptimalBeatTickInterval,
|
||||
useOptimalTickInterval,
|
||||
useTicksBounds,
|
||||
} from "@/lib/TinelineTicks";
|
||||
import { usePx } from "@/lib/vue";
|
||||
import { useTimelineStore } from "@/store/TimelineStore";
|
||||
import { storeToRefs } from "pinia";
|
||||
import {
|
||||
computed,
|
||||
type ComputedRef,
|
||||
type MaybeRefOrGetter,
|
||||
toValue,
|
||||
} from "vue";
|
||||
|
||||
export interface Ticks<T extends AnyTime> {
|
||||
tickIn: ComputedRef<T>;
|
||||
tickOut: ComputedRef<T>;
|
||||
interval: ComputedRef<T>;
|
||||
/** An inclusive range from tickIn to tickOut, with `interval` step. */
|
||||
ticks: ComputedRef<T[]>;
|
||||
width: ComputedRef<Pixels>;
|
||||
widthPx: ComputedRef<string>;
|
||||
left: (tickIn: T) => ComputedRef<Pixels>;
|
||||
label: (tickIn: T) => ComputedRef<string>;
|
||||
}
|
||||
|
||||
export function useTimelineTicks<T extends AnyTime>(
|
||||
viewportIn: MaybeRefOrGetter<T>,
|
||||
viewportOut: MaybeRefOrGetter<T>,
|
||||
interval: ComputedRef<T>,
|
||||
intervalToPixels: (interval: T) => Pixels,
|
||||
positionToPixels: (position: T) => Pixels,
|
||||
positionToLabel: (position: T) => string,
|
||||
): Ticks<T> {
|
||||
const ticksBounds = useTicksBounds(interval, viewportIn, viewportOut);
|
||||
|
||||
const tickIn = computed(() => ticksBounds.value.tickIn);
|
||||
const tickOut = computed(() => ticksBounds.value.tickOut);
|
||||
const ticks = computed(() =>
|
||||
rangeInclusive(tickIn.value, tickOut.value, toValue(interval)) as T[]
|
||||
);
|
||||
const width = computed(() => intervalToPixels(toValue(interval)));
|
||||
const widthPx = usePx(width);
|
||||
|
||||
return {
|
||||
tickIn,
|
||||
tickOut,
|
||||
interval,
|
||||
ticks,
|
||||
width,
|
||||
widthPx,
|
||||
left: (tickIn) => computed(() => positionToPixels(tickIn)),
|
||||
label: (tickIn) => computed(() => positionToLabel(tickIn)),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: cache / singletone / store?
|
||||
|
||||
export function useTimelineTicksSeconds(): Ticks<Seconds> {
|
||||
const timeline = useTimelineStore();
|
||||
const {
|
||||
contentWidthIncludingEmptySpace,
|
||||
durationIncludingEmptySpace,
|
||||
viewportInSeconds,
|
||||
viewportOutSeconds,
|
||||
} = storeToRefs(timeline);
|
||||
|
||||
return useTimelineTicks<Seconds>(
|
||||
viewportInSeconds,
|
||||
viewportOutSeconds,
|
||||
useOptimalTickInterval(
|
||||
contentWidthIncludingEmptySpace,
|
||||
durationIncludingEmptySpace,
|
||||
),
|
||||
(interval) => timeline.secondsToPixels(interval),
|
||||
(position) => timeline.secondsToPixels(position),
|
||||
(position) => formatTime(position, 2),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTimelineTicksBeats(): Ticks<Beats> {
|
||||
const timeline = useTimelineStore();
|
||||
const {
|
||||
contentWidthIncludingEmptySpace,
|
||||
durationBeatsIncludingEmptySpace,
|
||||
viewportInLoopOffsetBeats,
|
||||
viewportOutLoopOffsetBeats,
|
||||
} = storeToRefs(timeline);
|
||||
|
||||
return useTimelineTicks<Beats>(
|
||||
viewportInLoopOffsetBeats,
|
||||
viewportOutLoopOffsetBeats,
|
||||
useOptimalBeatTickInterval(
|
||||
contentWidthIncludingEmptySpace,
|
||||
durationBeatsIncludingEmptySpace,
|
||||
),
|
||||
(interval) => timeline.beatsToPixels(interval),
|
||||
(position) => timeline.loopOffsetBeatsToPixels(position),
|
||||
(position) => position.toFixed(),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { computed, nextTick, shallowRef } from "vue";
|
||||
import type { UseZoomAxis } from ".";
|
||||
import { useZoom, useZoomAxis, zoomDiscreteToRaw, zoomRawToDiscrete } from ".";
|
||||
|
||||
describe("zoom conversion", () => {
|
||||
test("zoomRawToDiscrete", () => {
|
||||
expect(zoomRawToDiscrete(1)).toBe(0);
|
||||
expect(zoomRawToDiscrete(3)).toBe(32);
|
||||
expect(zoomRawToDiscrete(10)).toBe(122);
|
||||
expect(zoomRawToDiscrete(19.75)).toBe(200);
|
||||
// negative
|
||||
expect(zoomRawToDiscrete(1)).toBe(0);
|
||||
expect(zoomRawToDiscrete(0.75)).toBe(-10);
|
||||
expect(zoomRawToDiscrete(0.5)).toBe(-20);
|
||||
});
|
||||
test("zoomDiscreteToRaw", () => {
|
||||
expect(zoomDiscreteToRaw(0)).toBe(1);
|
||||
expect(zoomDiscreteToRaw(32)).toBe(3);
|
||||
expect(zoomDiscreteToRaw(122)).toBe(10);
|
||||
expect(zoomDiscreteToRaw(200)).toBe(19.75);
|
||||
// negative
|
||||
expect(zoomDiscreteToRaw(0)).toBe(1);
|
||||
expect(zoomDiscreteToRaw(-10)).toBe(0.75);
|
||||
expect(zoomDiscreteToRaw(-20)).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useZoom", () => {
|
||||
test("baseline", () => {
|
||||
useZoom({ raw: 1 });
|
||||
useZoom({ raw: 2, min: 0, max: 100 });
|
||||
useZoom({ raw: 3, min: -20, max: 200 });
|
||||
useZoom({ raw: shallowRef(4) });
|
||||
const rawRef = shallowRef(5);
|
||||
const rawComputed = computed({
|
||||
get() {
|
||||
return rawRef.value;
|
||||
},
|
||||
set(value) {
|
||||
rawRef.value = value;
|
||||
},
|
||||
});
|
||||
const zoom = useZoom({ raw: rawComputed });
|
||||
expect(zoom.discrete.value).toBeDefined();
|
||||
expect(zoom.raw.value).toBeDefined();
|
||||
});
|
||||
test("reactive", async () => {
|
||||
// start with a reactive raw ref
|
||||
const rawRef = shallowRef(1);
|
||||
const zoom = useZoom({ raw: rawRef, min: -20, max: 200 });
|
||||
|
||||
// initial linkage
|
||||
expect(zoom.raw.value).toBe(1);
|
||||
expect(zoom.discrete.value).toBe(zoomRawToDiscrete(1));
|
||||
|
||||
// updating source raw should update discrete
|
||||
rawRef.value = 3;
|
||||
await nextTick();
|
||||
expect(zoom.raw.value).toBe(3);
|
||||
expect(zoom.discrete.value).toBe(zoomRawToDiscrete(3));
|
||||
|
||||
// updating discrete should update raw
|
||||
zoom.discrete.value = 122;
|
||||
await nextTick();
|
||||
expect(zoom.discrete.value).toBe(122);
|
||||
expect(zoom.raw.value).toBe(zoomDiscreteToRaw(122));
|
||||
expect(rawRef.value).toBe(zoomDiscreteToRaw(122));
|
||||
|
||||
// setting discrete beyond max should clamp
|
||||
zoom.discrete.value = 1000;
|
||||
await nextTick();
|
||||
expect(zoom.discrete.value).toBe(200); // clamped to max
|
||||
expect(zoom.raw.value).toBe(zoomDiscreteToRaw(200));
|
||||
expect(rawRef.value).toBe(zoomDiscreteToRaw(200));
|
||||
|
||||
// setting raw via the wrapper should update discrete
|
||||
zoom.raw.value = 0.5;
|
||||
await nextTick();
|
||||
expect(zoom.raw.value).toBe(0.5);
|
||||
expect(zoom.discrete.value).toBe(zoomRawToDiscrete(0.5));
|
||||
expect(rawRef.value).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useZoomAxis", () => {
|
||||
test("baseline", () => {
|
||||
expect(useZoomAxis).toBeDefined();
|
||||
const zoom: UseZoomAxis = useZoomAxis({ raw: 1 });
|
||||
expect(zoom.zoom.discrete.value).toBeDefined();
|
||||
expect(zoom.min.discrete.value).toBeDefined();
|
||||
expect(zoom.max.discrete.value).toBeDefined();
|
||||
expect(zoom.default.discrete.value).toBeDefined();
|
||||
expect(zoom.stepSmall.discrete.value).toBeDefined();
|
||||
zoom.reset();
|
||||
zoom.zoomIn();
|
||||
zoom.zoomOut();
|
||||
|
||||
useZoomAxis({
|
||||
raw: 1,
|
||||
min: 0,
|
||||
max: 10,
|
||||
default: 5,
|
||||
stepSmall: 2,
|
||||
stepBig: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("readonly properties are readonly at compile time", () => {
|
||||
const zoom = useZoomAxis({ raw: 1 });
|
||||
|
||||
// These lines assert, at compile time, that the properties are readonly.
|
||||
// If any of these assignments do NOT produce a TS error, the TypeScript
|
||||
// compiler will fail due to the @ts-expect-error directive.
|
||||
|
||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||
zoom.min.raw.value = 32;
|
||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||
zoom.min.discrete.value = 32;
|
||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||
zoom.default.raw.value = 2;
|
||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||
zoom.stepSmall.raw.value = 2;
|
||||
});
|
||||
|
||||
test("reset sets zoom to default", async () => {
|
||||
const z = useZoomAxis({
|
||||
raw: 1,
|
||||
default: 5,
|
||||
});
|
||||
|
||||
// change zoom and ensure reset restores default
|
||||
z.zoom.discrete.value = 0;
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(0);
|
||||
|
||||
z.reset();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(5);
|
||||
});
|
||||
|
||||
test("zoom.raw is writable and reflects source ref", async () => {
|
||||
const rawRef = shallowRef(1);
|
||||
const z = useZoomAxis({ raw: rawRef });
|
||||
|
||||
expect(z.zoom.raw.value).toBe(1);
|
||||
|
||||
z.zoom.raw.value = 3;
|
||||
await nextTick();
|
||||
|
||||
expect(z.zoom.raw.value).toBe(3);
|
||||
expect(rawRef.value).toBe(3);
|
||||
});
|
||||
|
||||
test("zoomIn / zoomOut are callable (no runtime throw)", () => {
|
||||
const z = useZoomAxis({ raw: 1 });
|
||||
expect(() => {
|
||||
z.zoomIn();
|
||||
z.zoomOut();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("zoomIn snaps up to next big step when between steps", async () => {
|
||||
const z = useZoomAxis({ raw: 1, stepBig: 10 });
|
||||
// set to a value between 10 and 20
|
||||
z.zoom.discrete.value = 15;
|
||||
await nextTick();
|
||||
z.zoomIn();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(20);
|
||||
});
|
||||
|
||||
test("zoomOut snaps down to previous big step when between steps", async () => {
|
||||
const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
|
||||
z.zoomOut();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(10);
|
||||
});
|
||||
|
||||
test("zoomIn snaps down to previous big step when between steps", async () => {
|
||||
const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
|
||||
z.zoomIn();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(20);
|
||||
});
|
||||
|
||||
test("aligned steps add/subtract a whole big step", async () => {
|
||||
const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), stepBig: 10 });
|
||||
|
||||
z.zoomIn();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(30);
|
||||
|
||||
z.zoomOut();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(20);
|
||||
|
||||
z.zoomOut();
|
||||
await nextTick();
|
||||
expect(z.zoom.discrete.value).toBe(10);
|
||||
});
|
||||
|
||||
test("zoomIn clamps to max when stepping beyond max", async () => {
|
||||
const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), max: 25, stepBig: 10 });
|
||||
|
||||
z.zoomIn();
|
||||
await nextTick();
|
||||
// should clamp to max (25) instead of exceeding it
|
||||
expect(z.zoom.discrete.value).toBe(25);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
import type { Px, ZoomDiscrete, ZoomRaw } from "@/lib/units";
|
||||
import { clamp } from "@vueuse/core";
|
||||
import {
|
||||
computed,
|
||||
type ComputedRef,
|
||||
type DeepReadonly,
|
||||
type MaybeRef,
|
||||
type Ref,
|
||||
shallowRef,
|
||||
toRef,
|
||||
toValue,
|
||||
type WritableComputedRef,
|
||||
} from "vue";
|
||||
|
||||
export function useZoomAxisOld(
|
||||
{
|
||||
contentSizeForZoom,
|
||||
viewportScrollOffset,
|
||||
viewportSize,
|
||||
zoom,
|
||||
zoomMin,
|
||||
zoomMax,
|
||||
}: {
|
||||
contentSizeForZoom: (zoom?: ZoomRaw) => Px;
|
||||
viewportScrollOffset: Ref<Px>;
|
||||
viewportSize: Ref<Px>;
|
||||
zoom: Ref<ZoomRaw>;
|
||||
zoomMin: ZoomRaw;
|
||||
zoomMax: ZoomRaw;
|
||||
},
|
||||
) {
|
||||
const contentSize = computed<number>(() => contentSizeForZoom());
|
||||
|
||||
function contentSizeIncludingEmptySpaceForZoom(zoom?: ZoomRaw): Px {
|
||||
return Math.max(contentSizeForZoom(zoom), viewportSize.value);
|
||||
}
|
||||
const contentSizeIncludingEmptySpace = computed<Px>(() =>
|
||||
contentSizeIncludingEmptySpaceForZoom()
|
||||
);
|
||||
|
||||
// When zooming, timeline should stay centered at current viewport center
|
||||
const zoomWrapper = computed<ZoomRaw>({
|
||||
get() {
|
||||
return zoom.value;
|
||||
},
|
||||
set(value) {
|
||||
// sanitize
|
||||
value = clamp(value, zoomMin, zoomMax);
|
||||
// calculate current and anticipated content size
|
||||
const currentContentSize = contentSizeIncludingEmptySpaceForZoom();
|
||||
const nextContentSize = contentSizeIncludingEmptySpaceForZoom(
|
||||
value,
|
||||
);
|
||||
// calculate current offset of center
|
||||
const halfViewportSize = viewportSize.value / 2;
|
||||
const currentOffsetOfCenter = viewportScrollOffset.value +
|
||||
halfViewportSize;
|
||||
|
||||
// keep the timeline centered around current viewport's center
|
||||
const percent = currentOffsetOfCenter / currentContentSize;
|
||||
const nextOffsetOfCenter = percent * nextContentSize;
|
||||
let nextOffset = nextOffsetOfCenter - halfViewportSize;
|
||||
const maxOffset = nextContentSize - viewportSize.value;
|
||||
nextOffset = clamp(nextOffset, 0, maxOffset);
|
||||
|
||||
zoom.value = value;
|
||||
window.requestAnimationFrame(() => {
|
||||
viewportScrollOffset.value = nextOffset;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
contentSize,
|
||||
contentSizeIncludingEmptySpaceForZoom,
|
||||
contentSizeIncludingEmptySpace,
|
||||
zoom: zoomWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseZoomAxisManagerOptions {
|
||||
contentSizeForZoom: (zoom: ZoomRaw) => Px;
|
||||
viewportScrollOffset: Ref<Px>;
|
||||
viewportSize: Readonly<Ref<Px>>;
|
||||
zoom: Ref<ZoomDiscrete>;
|
||||
zoomMin: ZoomDiscrete;
|
||||
zoomMax: ZoomDiscrete;
|
||||
defaultZoom: ZoomDiscrete;
|
||||
}
|
||||
|
||||
export interface UseZoomAxisManagerReturn {
|
||||
contentSize: ComputedRef<Px>;
|
||||
contentSizeIncludingEmptySpaceForZoom: (zoom?: ZoomRaw) => Px;
|
||||
contentSizeIncludingEmptySpace: ComputedRef<Px>;
|
||||
|
||||
axis: UseZoomAxis;
|
||||
}
|
||||
|
||||
const SCALE_INVERSE = 40;
|
||||
const SCALE_BELOW_THESHOLD = 16;
|
||||
// no exponential growth: scale is steeper after the threshold
|
||||
const SCALE_ABOVE_TRESHOLD = 8;
|
||||
const SCALE_THRESHOLD_DISCRETE: ZoomDiscrete = 100;
|
||||
const SCALE_THRESHOLD_RAW: ZoomRaw = SCALE_THRESHOLD_DISCRETE /
|
||||
SCALE_BELOW_THESHOLD;
|
||||
|
||||
export function zoomRawToDiscrete(zoom: ZoomRaw): ZoomDiscrete {
|
||||
// normalize around zero, so it can scale lineraly
|
||||
zoom = zoom - 1;
|
||||
|
||||
if (zoom >= 0) {
|
||||
let belowThreshold = zoom;
|
||||
let aboveTreshold = 0;
|
||||
if (zoom > SCALE_THRESHOLD_RAW) {
|
||||
belowThreshold = SCALE_THRESHOLD_RAW;
|
||||
aboveTreshold = zoom - SCALE_THRESHOLD_RAW;
|
||||
}
|
||||
return belowThreshold * SCALE_BELOW_THESHOLD +
|
||||
aboveTreshold * SCALE_ABOVE_TRESHOLD;
|
||||
} else {
|
||||
return SCALE_INVERSE * zoom;
|
||||
}
|
||||
}
|
||||
|
||||
export function zoomDiscreteToRaw(zoom: ZoomDiscrete): ZoomRaw {
|
||||
if (zoom >= 0) {
|
||||
let belowThreshold = zoom;
|
||||
let aboveTreshold = 0;
|
||||
if (zoom > SCALE_THRESHOLD_DISCRETE) {
|
||||
belowThreshold = SCALE_THRESHOLD_DISCRETE;
|
||||
aboveTreshold = zoom - SCALE_THRESHOLD_DISCRETE;
|
||||
}
|
||||
return 1 + belowThreshold / SCALE_BELOW_THESHOLD +
|
||||
aboveTreshold / SCALE_ABOVE_TRESHOLD;
|
||||
} else {
|
||||
return 1 + zoom / SCALE_INVERSE;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseZoomOptions {
|
||||
raw: MaybeRef<ZoomRaw>;
|
||||
min?: ZoomDiscrete;
|
||||
max?: ZoomDiscrete;
|
||||
}
|
||||
|
||||
export interface UseZoom {
|
||||
raw: Ref<ZoomRaw>;
|
||||
discrete: Ref<ZoomDiscrete>;
|
||||
}
|
||||
|
||||
const DEFAULT_ZOOM_DISCRETE: ZoomDiscrete = 0;
|
||||
const DEFAULT_ZOOM_MIN_DISCRETE: ZoomDiscrete = 0;
|
||||
const DEFAULT_ZOOM_MAX_DISCRETE: ZoomDiscrete = 100;
|
||||
const DEFAULT_ZOOM_STEP_BIG_DISCRETE: ZoomDiscrete = 10;
|
||||
const DEFAULT_ZOOM_STEP_SMALL_DISCRETE: ZoomDiscrete = 1;
|
||||
|
||||
/**
|
||||
* zoom raw: suitable for scaling, range 1 .. 7.25
|
||||
* zoom discrete: suitable for sliders, range 0 .. 100 or -20 .. 100
|
||||
*/
|
||||
export function useZoom(
|
||||
options: UseZoomOptions,
|
||||
): UseZoom {
|
||||
const {
|
||||
min: minDiscrete = DEFAULT_ZOOM_MIN_DISCRETE,
|
||||
max: maxDiscrete = DEFAULT_ZOOM_MAX_DISCRETE,
|
||||
} = options;
|
||||
|
||||
const minRaw = zoomDiscreteToRaw(minDiscrete);
|
||||
const maxRaw = zoomDiscreteToRaw(maxDiscrete);
|
||||
|
||||
const rawRef = toRef(options.raw);
|
||||
const raw = computed({
|
||||
get() {
|
||||
return clamp(rawRef.value, minRaw, maxRaw);
|
||||
},
|
||||
set(newRaw) {
|
||||
rawRef.value = clamp(newRaw, minRaw, maxRaw);
|
||||
},
|
||||
});
|
||||
|
||||
const discrete = computed({
|
||||
get() {
|
||||
return clamp(zoomRawToDiscrete(rawRef.value), minDiscrete, maxDiscrete);
|
||||
},
|
||||
set(newDiscrete) {
|
||||
rawRef.value = zoomDiscreteToRaw(
|
||||
clamp(newDiscrete, minDiscrete, maxDiscrete),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
raw,
|
||||
discrete,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseZoomAxisOptions {
|
||||
raw: MaybeRef<ZoomRaw>;
|
||||
// limits
|
||||
min?: ZoomDiscrete;
|
||||
max?: ZoomDiscrete;
|
||||
default?: ZoomDiscrete;
|
||||
// Can be used for granular controls like a range slider
|
||||
stepSmall?: ZoomDiscrete;
|
||||
// Can be used for buttons
|
||||
stepBig?: ZoomDiscrete;
|
||||
}
|
||||
|
||||
export interface UseZoomAxis {
|
||||
zoom: UseZoom;
|
||||
|
||||
// Can be used by granular controls like a range slider
|
||||
min: DeepReadonly<UseZoom>;
|
||||
max: DeepReadonly<UseZoom>;
|
||||
default: DeepReadonly<UseZoom>;
|
||||
stepSmall: DeepReadonly<UseZoom>;
|
||||
|
||||
// Can be triggered by double clicking on the control
|
||||
reset: () => void;
|
||||
// Can be used by buttons. Zoom values between big steps will snap to the nearest whole step in the given direction, otherwise adds or subtracts a whole step.
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
}
|
||||
|
||||
export function useZoomAxis(options: UseZoomAxisOptions): UseZoomAxis {
|
||||
const {
|
||||
raw,
|
||||
min: minDiscrete = DEFAULT_ZOOM_MIN_DISCRETE,
|
||||
max: maxDiscrete = DEFAULT_ZOOM_MAX_DISCRETE,
|
||||
default: defaultDiscrete = DEFAULT_ZOOM_DISCRETE,
|
||||
stepSmall: stepSmallDiscrete = DEFAULT_ZOOM_STEP_SMALL_DISCRETE,
|
||||
stepBig: stepBigDiscrete = DEFAULT_ZOOM_STEP_BIG_DISCRETE,
|
||||
} = options;
|
||||
|
||||
const zoom = useZoom({ raw, min: minDiscrete, max: maxDiscrete });
|
||||
const min = useZoom({ raw: zoomDiscreteToRaw(minDiscrete) });
|
||||
const max = useZoom({ raw: zoomDiscreteToRaw(maxDiscrete) });
|
||||
const default_ = useZoom({ raw: zoomDiscreteToRaw(defaultDiscrete) });
|
||||
const stepSmall = useZoom({ raw: zoomDiscreteToRaw(stepSmallDiscrete) });
|
||||
|
||||
function reset() {
|
||||
zoom.discrete.value = defaultDiscrete;
|
||||
}
|
||||
function zoomStep(direction: number): void {
|
||||
let z = zoom.discrete.value - minDiscrete;
|
||||
if (z % stepBigDiscrete !== 0) {
|
||||
// go to the nearest full step up or down depending on the direction
|
||||
z = ((direction > 0) ? Math.ceil : Math.floor)(z / stepBigDiscrete);
|
||||
zoom.discrete.value = minDiscrete + z * stepBigDiscrete;
|
||||
} else {
|
||||
zoom.discrete.value += direction * stepBigDiscrete;
|
||||
}
|
||||
}
|
||||
function zoomIn() {
|
||||
zoomStep(+1);
|
||||
}
|
||||
function zoomOut() {
|
||||
zoomStep(-1);
|
||||
}
|
||||
|
||||
return {
|
||||
zoom,
|
||||
|
||||
min,
|
||||
max,
|
||||
default: default_,
|
||||
stepSmall,
|
||||
|
||||
reset,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
};
|
||||
}
|
||||
|
||||
// export function useZoomAxisManager(
|
||||
// {
|
||||
// contentSizeForZoom,
|
||||
// viewportScrollOffset,
|
||||
// viewportSize,
|
||||
// zoom,
|
||||
// zoomMin,
|
||||
// zoomMax,
|
||||
// }: UseZoomAxisManagerOptions,
|
||||
// ): UseZoomAxisManagerReturn {
|
||||
// const contentSize = computed<Px>(() => contentSizeForZoom());
|
||||
|
||||
// function contentSizeIncludingEmptySpaceForZoom(zoom?: number): number {
|
||||
// return Math.max(contentSizeForZoom(zoom), viewportSize.value);
|
||||
// }
|
||||
// const contentSizeIncludingEmptySpace = computed<number>(() =>
|
||||
// contentSizeIncludingEmptySpaceForZoom()
|
||||
// );
|
||||
|
||||
// // When zooming, timeline should stay centered at current viewport center
|
||||
// const zoomWrapper = computed<number>({
|
||||
// get() {
|
||||
// return zoom.value;
|
||||
// },
|
||||
// set(value) {
|
||||
// // sanitize
|
||||
// value = clamp(value, zoomMin, zoomMax);
|
||||
// // calculate current and anticipated content size
|
||||
// const currentContentSize = contentSizeIncludingEmptySpaceForZoom();
|
||||
// const nextContentSize = contentSizeIncludingEmptySpaceForZoom(
|
||||
// value,
|
||||
// );
|
||||
// // calculate current offset of center
|
||||
// const halfViewportSize = viewportSize.value / 2;
|
||||
// const currentOffsetOfCenter = viewportScrollOffset.value +
|
||||
// halfViewportSize;
|
||||
|
||||
// // keep the timeline centered around current viewport's center
|
||||
// const percent = currentOffsetOfCenter / currentContentSize;
|
||||
// const nextOffsetOfCenter = percent * nextContentSize;
|
||||
// let nextOffset = nextOffsetOfCenter - halfViewportSize;
|
||||
// const maxOffset = nextContentSize - viewportSize.value;
|
||||
// nextOffset = clamp(nextOffset, 0, maxOffset);
|
||||
|
||||
// zoom.value = value;
|
||||
// viewportScrollOffset.value = nextOffset;
|
||||
// },
|
||||
// });
|
||||
|
||||
// return {
|
||||
// contentSize,
|
||||
// contentSizeIncludingEmptySpaceForZoom,
|
||||
// contentSizeIncludingEmptySpace,
|
||||
// zoom: zoomWrapper,
|
||||
// };
|
||||
// }
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
computed,
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
toValue,
|
||||
watch,
|
||||
type WatchHandle,
|
||||
} from "vue";
|
||||
|
||||
export function usePx(value: MaybeRefOrGetter<number>) {
|
||||
return computed(() => toPx(value));
|
||||
}
|
||||
|
||||
export function toPx(value: MaybeRefOrGetter<number>) {
|
||||
return `${toValue(value)}px`;
|
||||
}
|
||||
|
||||
function multiWatchHandle(...handles: WatchHandle[]): WatchHandle {
|
||||
const watchHandle = () => {
|
||||
handles.forEach((h) => h.stop());
|
||||
}
|
||||
watchHandle.pause = () => handles.forEach((h) => h.pause());
|
||||
watchHandle.resume = () => handles.forEach((h) => h.resume());
|
||||
watchHandle.stop = watchHandle;
|
||||
|
||||
return watchHandle;
|
||||
}
|
||||
|
||||
export function bindTwoWay<T>(ref1: Ref<T>, ref2: Ref<T>): WatchHandle {
|
||||
let updating = false;
|
||||
|
||||
function update(other: Ref<T>, value: T) {
|
||||
if (updating) return;
|
||||
// For some reason, useScroll reports undefined values sometimes
|
||||
if (value === undefined) return;
|
||||
updating = true;
|
||||
try {
|
||||
other.value = value;
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
const handle1 = watch(ref1, (value) => update(ref2, value));
|
||||
const handle2 = watch(ref2, (value) => update(ref1, value));
|
||||
|
||||
return multiWatchHandle(handle1, handle2);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import "@/style.css";
|
||||
import App from "@/App.vue";
|
||||
import { router } from "@/router";
|
||||
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
Josh's Custom CSS Reset
|
||||
https://www.joshwcomeau.com/css/custom-css-reset/
|
||||
*/
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
#app {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import LibraryPage from "@/routes/LibraryPage.vue";
|
||||
import PlayerPage from "@/routes/PlayerPage.vue";
|
||||
import { computed, type ComputedRef, nextTick, shallowRef } from "vue";
|
||||
import { createRouter, createWebHashHistory, useRoute } from "vue-router";
|
||||
import type { AudioTrack } from "./lib/AudioTrack";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
component: LibraryPage,
|
||||
meta: {
|
||||
title() {
|
||||
return "Library";
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/track/:trackName",
|
||||
component: PlayerPage,
|
||||
meta: {
|
||||
title() {
|
||||
const trackName = router.currentRoute.value.params["trackName"] ??
|
||||
"No track selected";
|
||||
return trackName;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export const DEFAULT_TITLE = "MuzikaGromche";
|
||||
const titleRef = shallowRef("");
|
||||
|
||||
router.afterEach((to, _from) => {
|
||||
const route = useRoute();
|
||||
let title = "";
|
||||
if (typeof to.meta.title === "function") {
|
||||
title = to.meta.title(route);
|
||||
}
|
||||
titleRef.value = title;
|
||||
nextTick(() => {
|
||||
const documentTitle = title ? `${title} — ${DEFAULT_TITLE}` : DEFAULT_TITLE;
|
||||
document.title = documentTitle;
|
||||
});
|
||||
});
|
||||
|
||||
export function useTitle(): ComputedRef<string> {
|
||||
return computed(() => titleRef.value);
|
||||
}
|
||||
|
||||
export function openTrack(track: AudioTrack) {
|
||||
router.push(`/track/${track.Name}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import ErrorScreen from '@/components/ErrorScreen.vue';
|
||||
import LibraryContent from '@/components/library/LibraryContent.vue';
|
||||
import LoadingScreen from '@/components/LoadingScreen.vue';
|
||||
import { useScrollStore } from '@/store/ScrollStore';
|
||||
import { useTrackStore } from '@/store/TrackStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTemplateRef } from 'vue';
|
||||
|
||||
const scrollContainer = useTemplateRef('scrollContainer');
|
||||
const scrollStore = useScrollStore();
|
||||
scrollStore.bindScrollContainer(scrollContainer);
|
||||
|
||||
const trackStore = useTrackStore();
|
||||
const { status, progress, error } = storeToRefs(trackStore);
|
||||
trackStore.fill();
|
||||
</script>
|
||||
<template>
|
||||
<!-- grid is used to overlay the content area with a loading animation -->
|
||||
<div class="tw:h-full tw:grid">
|
||||
<!-- content area -->
|
||||
<div ref="scrollContainer" class="tw:col-span-full tw:row-span-full tw:overflow-scroll">
|
||||
<ErrorScreen v-if="status === 'error'" title="Error loading tracks!" :description="error" />
|
||||
|
||||
<div v-if="status === 'ready'" class="tw:h-full tw:isolate">
|
||||
<LibraryContent />
|
||||
</div>
|
||||
</div>
|
||||
<!-- loading area -->
|
||||
<LoadingScreen :visible="['null', 'loading'].includes(status)" class="tw:col-span-full tw:row-span-full"
|
||||
message="Loading the list of track…" :progress />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<script setup lang="ts">
|
||||
import LoadingScreen from '@/components/LoadingScreen.vue';
|
||||
import PreviewScnene from '@/components/editor/PreviewScnene.vue';
|
||||
import TrackInfo from '@/components/editor/TrackInfo.vue';
|
||||
import TimelinePanel from '@/components/timeline/TimelinePanel.vue';
|
||||
import { useScrollStore } from '@/store/ScrollStore';
|
||||
import { useTimelineStore } from '@/store/TimelineStore';
|
||||
import { useTrackStore } from '@/store/TrackStore';
|
||||
import { useEventListener, useRafFn } from '@vueuse/core';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute } from 'vue-router';
|
||||
import ErrorScreen from '@/components/ErrorScreen.vue';
|
||||
|
||||
const scrollContainer = useTemplateRef('scrollContainer');
|
||||
const scrollStore = useScrollStore();
|
||||
scrollStore.bindScrollContainer(scrollContainer);
|
||||
|
||||
const route = useRoute();
|
||||
const trackStore = useTrackStore();
|
||||
const timeline = useTimelineStore();
|
||||
|
||||
watch(() => String(route.params.trackName), fetchTrack, { immediate: true })
|
||||
|
||||
async function fetchTrack(trackName: string) {
|
||||
await trackStore.fill();
|
||||
await trackStore.setCurrentAudioTrackByName(trackName);
|
||||
timeline.setAudioTrack(trackStore.currentAudioTrack);
|
||||
}
|
||||
|
||||
const { currentAudioTrack, currentAudioTrackName, audioTrackStatus, audioTrackProgress, audioTrackError } = storeToRefs(trackStore);
|
||||
|
||||
const fpsLimit = 60
|
||||
const { pause, resume } = useRafFn(({ delta }) => {
|
||||
if (currentAudioTrack.value && trackStore.isPlaying) {
|
||||
const deltaSeconds = delta / 1000;
|
||||
timeline.advance(deltaSeconds);
|
||||
}
|
||||
}, { immediate: false, fpsLimit });
|
||||
|
||||
watch(() => trackStore.isPlaying, (isPlaying: boolean) => {
|
||||
if (isPlaying) {
|
||||
resume();
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === ' ') {
|
||||
if (!event.repeat) {
|
||||
trackStore.togglePlayPause();
|
||||
}
|
||||
// Prevent auto-repeated spacebar from triggering buttons
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
trackStore.stop();
|
||||
trackStore.setCurrentAudioTrackByName("");
|
||||
next();
|
||||
});
|
||||
onBeforeRouteUpdate((to, from, next) => {
|
||||
if (to.params.trackName !== from.params.trackName) {
|
||||
trackStore.pause();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const errorTitle = computed(() => audioTrackStatus.value === 'error'
|
||||
? route.params.trackName
|
||||
? `Error loading the track '${route.params.trackName}'`
|
||||
: "Error loading an unknown track"
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- grid is used to overlay the content area with a loading animation -->
|
||||
<div class="tw:h-full tw:grid">
|
||||
<!-- content area -->
|
||||
<div ref="scrollContainer" class="tw:col-span-full tw:row-span-full tw:overflow-scroll">
|
||||
<ErrorScreen v-if="audioTrackStatus === 'error'" :title="errorTitle" :description="audioTrackError" />
|
||||
|
||||
<div v-if="audioTrackStatus === 'ready'" class="tw:h-full tw:isolate">
|
||||
<div class="tw:h-full tw:max-h-full tw:flex tw:flex-col tw:gap-2">
|
||||
|
||||
<div class="tw:flex-1">
|
||||
<TrackInfo v-if="false && currentAudioTrack" :track="currentAudioTrack" :edit="false" />
|
||||
|
||||
<!-- TODO: debug data -->
|
||||
<div class="tw:flex tw:flex-col tw:items-center tw:p-8 tw:text-sm">
|
||||
<p>Viewport size:
|
||||
{{ timeline.viewportWidth.toFixed(2) }} x {{ timeline.viewportHeight.toFixed(2) }}
|
||||
</p>
|
||||
<p>Scroll Offset:
|
||||
{{ timeline.viewportScrollOffsetLeft.toFixed(2) }} x {{ timeline.viewportScrollOffsetTop.toFixed(2) }}
|
||||
</p>
|
||||
<p>Duration: {{ timeline.duration }}</p>
|
||||
<p>Viewport duration: {{ timeline.viewportDurationSeconds.toFixed(3) }}</p>
|
||||
<hr/>
|
||||
<p>Zoom: {{ timeline.viewportZoomHorizontal.toFixed(4) }} x {{ timeline.viewportZoomVertical.toFixed(4) }}</p>
|
||||
<p>Content size: {{ timeline.contentWidth }} x ({{ timeline.contentHeight }} = {{ timeline.trackHeight }}px x {{ timeline.visibleTracks.length }} tracks)</p>
|
||||
<p>including empty space: {{ timeline.contentWidthIncludingEmptySpace.toFixed(2) }}</p>
|
||||
<hr/>
|
||||
<p>Viewport In/Out ...Seconds: {{ timeline.viewportInSeconds.toFixed(3) }} .. {{ timeline.viewportOutSeconds.toFixed(3) }}</p>
|
||||
<p>... Loop Offset Beats: {{ timeline.viewportInLoopOffsetBeats.toFixed(3) }} .. {{ timeline.viewportOutLoopOffsetBeats.toFixed(3) }}</p>
|
||||
<hr/>
|
||||
<p>playheadPosition: {{ timeline.playheadPosition.toFixed(3) }}</p>
|
||||
</div>
|
||||
|
||||
<PreviewScnene v-if="false" />
|
||||
</div>
|
||||
|
||||
<TimelinePanel class="tw:flex-1 tw:min-h-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- loading area -->
|
||||
<LoadingScreen :visible="['null', 'loading'].includes(audioTrackStatus)" :progress="audioTrackProgress"
|
||||
class="tw:col-span-full tw:row-span-full tw:z-10" :message="`Loading track ${currentAudioTrackName}…`" />
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { emitter, useEvent } from "@/events";
|
||||
import { useScroll } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { type MaybeRefOrGetter, toValue, watch } from "vue";
|
||||
|
||||
export const useScrollStore = defineStore("scroll", {
|
||||
state: () => {
|
||||
return ({
|
||||
isAtTop: true,
|
||||
});
|
||||
},
|
||||
actions: {
|
||||
scrollToTop() {
|
||||
emitter.emit("scrollToTop");
|
||||
},
|
||||
bindScrollContainer(element: MaybeRefOrGetter<HTMLElement | null>) {
|
||||
const { y, arrivedState } = useScroll(element, { behavior: "smooth" });
|
||||
|
||||
watch(arrivedState, () => {
|
||||
this.isAtTop = arrivedState.top;
|
||||
}, { immediate: true });
|
||||
|
||||
useEvent("scrollToTop", () => {
|
||||
const _element = toValue(element);
|
||||
if (!_element) {
|
||||
return;
|
||||
}
|
||||
y.value = 0;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
import {
|
||||
type AudioTrack,
|
||||
beatsToSeconds,
|
||||
introWithLoopOffsetDurationSeconds,
|
||||
secondsToBeats,
|
||||
totalDurationSeconds,
|
||||
} from "@/lib/AudioTrack";
|
||||
import { modRange } from "@/lib/math";
|
||||
import {
|
||||
emptyTimelineTracksMap,
|
||||
generateClips,
|
||||
generateMarkers,
|
||||
type TimelineMarkerData,
|
||||
type TimelineTrackData,
|
||||
timelineTracksArray,
|
||||
} from "@/lib/Timeline";
|
||||
import type { Beats, Px, Seconds } from "@/lib/units";
|
||||
import { useZoomAxisOld } from "@/lib/useZoomAxis";
|
||||
import { toPx } from "@/lib/vue";
|
||||
import { clamp, useLocalStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, shallowRef } from "vue";
|
||||
|
||||
export const DEFAULT_ZOOM_HORIZONTAL = 1.0;
|
||||
export const DEFAULT_ZOOM_VERTICAL = 3.0;
|
||||
|
||||
const DEFAULT_HEADER_HEIGHT = 34; // px
|
||||
// TODO: on mobile default to 100px or even less
|
||||
const DEFAULT_SIDEBAR_WIDTH = 140; // px
|
||||
const DEFAULT_TRACK_HEIGHT = 72 / DEFAULT_ZOOM_VERTICAL; // px
|
||||
|
||||
const EXTRA_DURATION_AT_END_SECONDS = 0;
|
||||
|
||||
export const useTimelineStore = defineStore("timeline", {
|
||||
state: () => {
|
||||
// actual content
|
||||
const audioTrack = shallowRef<AudioTrack | null>(null);
|
||||
const tracksMap = shallowRef(emptyTimelineTracksMap());
|
||||
const markers = [] as TimelineMarkerData[];
|
||||
|
||||
// viewport size, i.e. size of the external scroll area.
|
||||
const viewportWidth = shallowRef(0);
|
||||
const viewportHeight = shallowRef(0);
|
||||
|
||||
// viewport scroll offset, i.e. position of the inner scrollable view.
|
||||
const viewportScrollOffsetTop = shallowRef(0);
|
||||
const _viewportScrollOffsetLeft = shallowRef(0);
|
||||
const viewportScrollOffsetLeft = computed({
|
||||
get() {
|
||||
return _viewportScrollOffsetLeft.value;
|
||||
},
|
||||
set(value) {
|
||||
_viewportScrollOffsetLeft.value = clamp(
|
||||
value,
|
||||
0,
|
||||
contentWidthIncludingEmptySpace.value - viewportWidth.value,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// horizontal zoom 1 equals to full timeline duration
|
||||
const _viewportZoomHorizontal = shallowRef(DEFAULT_ZOOM_HORIZONTAL);
|
||||
const _viewportZoomVertical = useLocalStorage(
|
||||
"timeline.viewportZoomVertical",
|
||||
DEFAULT_ZOOM_VERTICAL,
|
||||
);
|
||||
|
||||
function trackHeightForZoom(zoom: number = _viewportZoomVertical.value) {
|
||||
return Math.floor(zoom * DEFAULT_TRACK_HEIGHT);
|
||||
}
|
||||
const trackHeight = computed<number>(() => trackHeightForZoom());
|
||||
const tracksArray = computed<TimelineTrackData[]>(() =>
|
||||
timelineTracksArray(tracksMap.value)
|
||||
);
|
||||
const visibleTracks = computed<TimelineTrackData[]>(() =>
|
||||
// TODO: only show non-empty tracks, i.e. where clips.length>0?
|
||||
tracksArray.value.slice(0, 7).filter(() => true)
|
||||
);
|
||||
|
||||
function contentWidthForZoom(zoom: number = _viewportZoomHorizontal.value) {
|
||||
return Math.floor(zoom * viewportWidth.value);
|
||||
}
|
||||
function contentHeightForZoom(zoom?: number) {
|
||||
const trackHeight = trackHeightForZoom(zoom);
|
||||
return trackHeight * visibleTracks.value.length;
|
||||
}
|
||||
|
||||
// TODO: zoom around playhead
|
||||
const {
|
||||
contentSize: contentWidth,
|
||||
// contentSizeIncludingEmptySpaceForZoom: contentWidthIncludingEmptySpaceForZoom,
|
||||
contentSizeIncludingEmptySpace: contentWidthIncludingEmptySpace,
|
||||
zoom: viewportZoomHorizontal,
|
||||
} = useZoomAxisOld({
|
||||
contentSizeForZoom: contentWidthForZoom,
|
||||
viewportScrollOffset: viewportScrollOffsetLeft,
|
||||
viewportSize: viewportWidth,
|
||||
zoom: _viewportZoomHorizontal,
|
||||
zoomMin: 0.5,
|
||||
zoomMax: 19.75,
|
||||
});
|
||||
|
||||
const {
|
||||
contentSize: contentHeight,
|
||||
// contentSizeIncludingEmptySpaceForZoom: contentHeightIncludingEmptySpaceForZoom,
|
||||
contentSizeIncludingEmptySpace: contentHeightIncludingEmptySpace,
|
||||
zoom: viewportZoomVertical,
|
||||
} = useZoomAxisOld({
|
||||
contentSizeForZoom: contentHeightForZoom,
|
||||
viewportScrollOffset: viewportScrollOffsetTop,
|
||||
viewportSize: viewportHeight,
|
||||
zoom: _viewportZoomVertical,
|
||||
zoomMin: 1,
|
||||
zoomMax: 7.25,
|
||||
});
|
||||
|
||||
return ({
|
||||
// actual content
|
||||
audioTrack,
|
||||
tracksMap,
|
||||
markers,
|
||||
|
||||
// getters for tracks
|
||||
tracksArray,
|
||||
visibleTracks,
|
||||
|
||||
// timeline content's duration in seconds (maybe plus a bit of extra gap)
|
||||
duration: 0,
|
||||
|
||||
trackHeight,
|
||||
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
|
||||
// durationIncludingEmptySpace,
|
||||
contentWidthIncludingEmptySpace,
|
||||
contentHeightIncludingEmptySpace,
|
||||
|
||||
/* viewport bounds, updated by viewport's mounted HTML element. */
|
||||
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
|
||||
viewportScrollOffsetTop,
|
||||
viewportScrollOffsetLeft,
|
||||
|
||||
/* viewport zoom, managed by zoom sliders. */
|
||||
|
||||
// horizontal zoom 1 equals to full timeline duration
|
||||
viewportZoomHorizontal,
|
||||
viewportZoomVertical,
|
||||
|
||||
// playhead and scrubbing / preview positions in absolute seconds
|
||||
playheadPosition: 0,
|
||||
scrubbingPosition: NaN,
|
||||
|
||||
// auxilary elements
|
||||
headerHeight: DEFAULT_HEADER_HEIGHT,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
});
|
||||
},
|
||||
getters: {
|
||||
contentWidthIncludingEmptySpacePx(): string {
|
||||
return toPx(this.contentWidthIncludingEmptySpace);
|
||||
},
|
||||
contentHeightIncludingEmptySpacePx(): string {
|
||||
return toPx(this.contentHeightIncludingEmptySpace);
|
||||
},
|
||||
durationIncludingEmptySpace(): number {
|
||||
return this.viewportZoomHorizontal < 1
|
||||
? this.duration / this.viewportZoomHorizontal
|
||||
: this.duration;
|
||||
},
|
||||
durationBeatsIncludingEmptySpace(): number {
|
||||
if (!this.audioTrack) return 0;
|
||||
return secondsToBeats(this.audioTrack, this.durationIncludingEmptySpace);
|
||||
},
|
||||
/* timeline content's size in pixels */
|
||||
contentWidthPx(): string {
|
||||
return toPx(this.contentWidth);
|
||||
},
|
||||
contentHeightPx(): string {
|
||||
return toPx(this.contentHeight);
|
||||
},
|
||||
|
||||
/* viewport boundaries in absolute seconds, may be less than zero or greater then duration. */
|
||||
viewportInSeconds(): Seconds {
|
||||
return this.pixelsToSeconds(this.viewportScrollOffsetLeft);
|
||||
},
|
||||
viewportOutSeconds(): Seconds {
|
||||
return this.pixelsToSeconds(
|
||||
this.viewportScrollOffsetLeft + this.viewportWidth,
|
||||
);
|
||||
},
|
||||
viewportInLoopOffsetBeats(): Beats {
|
||||
if (!this.audioTrack) return 0;
|
||||
return secondsToBeats(
|
||||
this.audioTrack,
|
||||
this.viewportInSeconds -
|
||||
introWithLoopOffsetDurationSeconds(this.audioTrack),
|
||||
);
|
||||
},
|
||||
viewportOutLoopOffsetBeats(): Beats {
|
||||
if (!this.audioTrack) return 0;
|
||||
return secondsToBeats(
|
||||
this.audioTrack,
|
||||
this.viewportOutSeconds -
|
||||
introWithLoopOffsetDurationSeconds(this.audioTrack),
|
||||
);
|
||||
},
|
||||
viewportDurationSeconds(): Seconds {
|
||||
return this.pixelsToSeconds(this.viewportWidth);
|
||||
},
|
||||
viewportCenterSeconds(): Seconds {
|
||||
return this.viewportInSeconds +
|
||||
(this.viewportOutSeconds - this.viewportInSeconds) / 2;
|
||||
},
|
||||
viewportScrollOffset(): { top: Px; left: Px } {
|
||||
return {
|
||||
top: this.viewportScrollOffsetTop,
|
||||
left: this.viewportScrollOffsetLeft,
|
||||
};
|
||||
},
|
||||
viewportSide() {
|
||||
return (positionSeconds: Seconds): "left" | "right" => {
|
||||
return positionSeconds < this.viewportCenterSeconds ? "left" : "right";
|
||||
};
|
||||
},
|
||||
durationBeats(): Beats {
|
||||
if (!this.audioTrack) {
|
||||
return 0;
|
||||
}
|
||||
return secondsToBeats(this.audioTrack, this.duration);
|
||||
},
|
||||
playheadPositionBeats(): Beats {
|
||||
if (!this.audioTrack) {
|
||||
return 0;
|
||||
}
|
||||
return secondsToBeats(this.audioTrack, this.playheadPosition);
|
||||
},
|
||||
scrubbingPositionBeats(): Beats {
|
||||
if (!this.audioTrack) {
|
||||
return 0;
|
||||
}
|
||||
if (Number.isNaN(this.scrubbingPosition)) {
|
||||
return NaN;
|
||||
}
|
||||
return secondsToBeats(this.audioTrack, this.scrubbingPosition);
|
||||
},
|
||||
/* Measurements and convertions */
|
||||
pixelsToSeconds(_state) {
|
||||
return (pixels: number): number => {
|
||||
const { contentWidthIncludingEmptySpace, durationIncludingEmptySpace } =
|
||||
this;
|
||||
if (contentWidthIncludingEmptySpace === 0) {
|
||||
return 0;
|
||||
}
|
||||
const percent = pixels / contentWidthIncludingEmptySpace;
|
||||
const seconds = percent * durationIncludingEmptySpace;
|
||||
return seconds;
|
||||
};
|
||||
},
|
||||
secondsToPixels(_state) {
|
||||
return (seconds: number): number => {
|
||||
const { contentWidthIncludingEmptySpace, durationIncludingEmptySpace } =
|
||||
this;
|
||||
if (
|
||||
durationIncludingEmptySpace === 0 ||
|
||||
contentWidthIncludingEmptySpace === 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const percent = seconds / durationIncludingEmptySpace;
|
||||
// TODO: contentWidth - 1 from content width to avoid clipping out of bounds, or just make duration always longer?
|
||||
const pixels = percent * contentWidthIncludingEmptySpace;
|
||||
// TODO: do we need Math.round() at this level?
|
||||
return pixels;
|
||||
};
|
||||
},
|
||||
beatsToPixels(_state) {
|
||||
return (beats: number): number => {
|
||||
const {
|
||||
contentWidthIncludingEmptySpace,
|
||||
durationBeatsIncludingEmptySpace,
|
||||
} = this;
|
||||
if (
|
||||
durationBeatsIncludingEmptySpace === 0 ||
|
||||
contentWidthIncludingEmptySpace === 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const percent = beats / durationBeatsIncludingEmptySpace;
|
||||
// TODO: contentWidth - 1 from content width to avoid clipping out of bounds, or just make duration always longer?
|
||||
const pixels = percent * contentWidthIncludingEmptySpace;
|
||||
// TODO: do we need Math.round() at this level?
|
||||
return pixels;
|
||||
};
|
||||
},
|
||||
loopOffsetBeatsToPixels(_state) {
|
||||
return (beats: number): number => {
|
||||
if (!this.audioTrack) return 0;
|
||||
const pixels = this.beatsToPixels(beats) +
|
||||
this.secondsToPixels(
|
||||
introWithLoopOffsetDurationSeconds(this.audioTrack),
|
||||
);
|
||||
return pixels;
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
reset() {
|
||||
this.audioTrack = null;
|
||||
this.duration = 0;
|
||||
this.resetViewport();
|
||||
this.playheadPosition = 0;
|
||||
this.scrubbingPosition = NaN;
|
||||
this.tracksMap = emptyTimelineTracksMap();
|
||||
this.markers = [];
|
||||
},
|
||||
resetViewport() {
|
||||
this.viewportZoomHorizontal = DEFAULT_ZOOM_HORIZONTAL;
|
||||
// Keep it in local storage.
|
||||
// this.viewportZoomVertical = DEFAULT_ZOOM_VERTICAL;
|
||||
},
|
||||
zoomToLoop() {
|
||||
const { audioTrack } = this;
|
||||
if (!audioTrack) return;
|
||||
this.zoomToLoopOffsetBeats(0, audioTrack.Beats);
|
||||
},
|
||||
zoomToLoopOffsetBeats(in_: Beats, out_: Beats) {
|
||||
const { audioTrack } = this;
|
||||
if (!audioTrack || out_ <= in_) return;
|
||||
beatsToSeconds(audioTrack, in_);
|
||||
const inSeconds = introWithLoopOffsetDurationSeconds(audioTrack) +
|
||||
beatsToSeconds(audioTrack, in_);
|
||||
const duration = beatsToSeconds(audioTrack, out_ - in_);
|
||||
const outSeconds = inSeconds + duration;
|
||||
this.zoomToSeconds(inSeconds, outSeconds);
|
||||
},
|
||||
zoomToSeconds(in_: Seconds, out_: Seconds) {
|
||||
const { audioTrack } = this;
|
||||
if (!audioTrack || out_ <= in_) return;
|
||||
const duration = out_ - in_;
|
||||
const totalDuration = totalDurationSeconds(audioTrack);
|
||||
const zoom = totalDuration / duration;
|
||||
this.viewportZoomHorizontal = zoom;
|
||||
// let the viewport adjust and propagate size changes.
|
||||
window.requestAnimationFrame(() => {
|
||||
const left = this.secondsToPixels(in_);
|
||||
this.viewportScrollOffsetLeft = left;
|
||||
});
|
||||
},
|
||||
zoomToggleBetweenWholeAndLoop() {
|
||||
if (this.viewportZoomHorizontal !== 1) {
|
||||
this.viewportZoomHorizontal = 1;
|
||||
} else {
|
||||
this.zoomToLoop();
|
||||
}
|
||||
},
|
||||
setAudioTrack(track: AudioTrack | null) {
|
||||
if (!track) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
this.audioTrack = track;
|
||||
this.duration = totalDurationSeconds(track) +
|
||||
EXTRA_DURATION_AT_END_SECONDS;
|
||||
this.resetViewport();
|
||||
this.playheadPosition = 0;
|
||||
this.scrubbingPosition = NaN;
|
||||
// regenerate tracks content
|
||||
this.tracksMap = generateClips(track);
|
||||
this.markers = generateMarkers(track);
|
||||
},
|
||||
/** Update playback position */
|
||||
advance(deltaSeconds: number) {
|
||||
const { audioTrack } = this;
|
||||
if (!audioTrack) {
|
||||
return;
|
||||
}
|
||||
const startOfLoop = introWithLoopOffsetDurationSeconds(audioTrack);
|
||||
const endOfLoop = totalDurationSeconds(audioTrack);
|
||||
let position = this.playheadPosition + deltaSeconds;
|
||||
position = modRange(position, startOfLoop, endOfLoop);
|
||||
this.playheadPosition = position;
|
||||
this.ensurePlayheadWithinViewport();
|
||||
},
|
||||
ensurePlayheadWithinViewport() {
|
||||
if (
|
||||
this.playheadPosition < this.viewportInSeconds ||
|
||||
this.playheadPosition > this.viewportOutSeconds
|
||||
) {
|
||||
const EDGE_GAP_PERCENT = 0.10;
|
||||
const target = this.secondsToPixels(this.playheadPosition) -
|
||||
this.viewportWidth * EDGE_GAP_PERCENT;
|
||||
this.viewportScrollOffsetLeft = clamp(
|
||||
target,
|
||||
0,
|
||||
this.contentWidth - this.viewportWidth,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import audioEngine, { VOLUME_MAX } from "@/audio/AudioEngine";
|
||||
import { introWithLoopOffsetDurationSeconds, totalDurationSeconds, type AudioTrack, type Codenames, type Language } from "@/lib/AudioTrack";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { shallowRef } from "vue";
|
||||
import codenamesJsonUrl from "/MuzikaGromcheCodenames.json?url";
|
||||
import tracksJsonUrl from "/MuzikaGromcheTracks.json?url";
|
||||
import { sleep } from "@/lib/sleep";
|
||||
|
||||
// Don't mark it as unused, it is needed for debugging
|
||||
sleep(0);
|
||||
|
||||
/**
|
||||
* A Pinia Store responsible for library loading and communicating with audio engine.
|
||||
*/
|
||||
export const useTrackStore = defineStore("track", {
|
||||
state: () => {
|
||||
return ({
|
||||
version: null as string | null,
|
||||
|
||||
// Status of fetching library (playlist)
|
||||
status: "null" as "null" | "loading" | "ready" | "error",
|
||||
// loading progress: 0..1
|
||||
progress: 0,
|
||||
error: null as string | null,
|
||||
|
||||
// Status of fetching current track
|
||||
audioTrackStatus: "null" as "null" | "loading" | "ready" | "error",
|
||||
audioTrackProgress: 0 as number,
|
||||
audioTrackError: null as string | null,
|
||||
|
||||
audioTracks: [] as AudioTrack[],
|
||||
|
||||
currentAudioTrackName: useStorage("track-name", ""),
|
||||
currentAudioTrack: shallowRef<AudioTrack | null>(null),
|
||||
|
||||
// when muted, volume persists but not accounted for
|
||||
muted: useStorage("player-volume-muted", false),
|
||||
// persisted volume 0..1
|
||||
volume: useStorage("player-volume", 1),
|
||||
|
||||
// audio engine manages AudioContext and nodes; store keeps serializable state only
|
||||
isPlaying: false,
|
||||
// Playback time elapsed since start of intro until the audio was paused.
|
||||
// This is needed because Audio Nodes can not be resumed.
|
||||
playedDuration: 0,
|
||||
});
|
||||
},
|
||||
getters: {
|
||||
groupedSortedTracks(): [Language, AudioTrack[]][] {
|
||||
const languageBuckets = new Map<Language, AudioTrack[]>();
|
||||
for (const track of this.audioTracks) {
|
||||
const bucket = languageBuckets.get(track.Language) ?? [];
|
||||
languageBuckets.set(track.Language, bucket);
|
||||
bucket.push(track);
|
||||
}
|
||||
for (const bucket of languageBuckets.values()) {
|
||||
bucket.sort((a, b) => a.Name.localeCompare(b.Name));
|
||||
}
|
||||
return Array.from(languageBuckets.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
},
|
||||
findTrackNamed(state) {
|
||||
return (trackName: string): AudioTrack | null => {
|
||||
return state.audioTracks.find((track) => track.Name === trackName) ??
|
||||
null;
|
||||
};
|
||||
},
|
||||
// TODO: replace with TimelineStore
|
||||
timelineTotalDurationSeconds(state) {
|
||||
return (track: AudioTrack | null = state.currentAudioTrack) => {
|
||||
return track ? totalDurationSeconds(track) : 0;
|
||||
};
|
||||
},
|
||||
trackStartOfLoopSeconds(state) {
|
||||
return (track: AudioTrack | null = state.currentAudioTrack) => {
|
||||
return track ? introWithLoopOffsetDurationSeconds(track) : 0;
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async fill(signal?: AbortSignal) {
|
||||
if (this.status === "ready" && this.audioTracks.length > 0) {
|
||||
return;
|
||||
}
|
||||
this.status = "loading";
|
||||
this.progress = 0;
|
||||
this.error = null;
|
||||
this.audioTracks = [];
|
||||
// await sleep(200);
|
||||
this.progress = 0.3;
|
||||
// await sleep(600);
|
||||
try {
|
||||
const jsonTracks = await fetch(tracksJsonUrl, { signal }).then((res) =>
|
||||
res.json()
|
||||
);
|
||||
this.version = jsonTracks["version"];
|
||||
const tracks: Partial<AudioTrack>[] = jsonTracks["tracks"];
|
||||
const codenames: Codenames = await fetch(codenamesJsonUrl, { signal })
|
||||
.then((res) => res.json());
|
||||
for (const t of tracks) {
|
||||
const codename = codenames[t.Name!];
|
||||
t.Artist = codename?.Artist ?? "";
|
||||
t.Song = codename?.Song ?? "";
|
||||
t.loadedIntro = null;
|
||||
t.loadedLoop = null;
|
||||
}
|
||||
this.audioTracks = tracks as AudioTrack[];
|
||||
this.progress = 1.0;
|
||||
this.status = "ready";
|
||||
} catch (err) {
|
||||
this.error = String(err);
|
||||
this.progress = 1.0;
|
||||
this.status = "error";
|
||||
}
|
||||
// initialize audio engine volume from persisted value
|
||||
this.setVolumeImpl();
|
||||
await this.setCurrentAudioTrackByName(this.currentAudioTrackName, signal);
|
||||
},
|
||||
|
||||
setMuted(muted: boolean) {
|
||||
if (!muted && this.volume === 0) {
|
||||
this.volume = 0.5;
|
||||
}
|
||||
this.muted = muted;
|
||||
this.setVolumeImpl();
|
||||
},
|
||||
|
||||
setVolume(value: number) {
|
||||
// clamp 0..1
|
||||
const v = Math.max(0, Math.min(VOLUME_MAX, value));
|
||||
// update persisted storage
|
||||
this.volume = v;
|
||||
this.muted = v === 0;
|
||||
this.setVolumeImpl();
|
||||
},
|
||||
|
||||
setVolumeImpl() {
|
||||
const v = this.muted ? 0 : this.volume;
|
||||
audioEngine.setVolume(v);
|
||||
},
|
||||
|
||||
async setCurrentAudioTrackByName(trackName: string, signal?: AbortSignal) {
|
||||
this.pause();
|
||||
this.rewindToIntro();
|
||||
this.currentAudioTrackName = trackName;
|
||||
const track = this.findTrackNamed(trackName);
|
||||
this.currentAudioTrack = track;
|
||||
this.audioTrackStatus = "null";
|
||||
this.audioTrackProgress = 0;
|
||||
this.audioTrackError = null;
|
||||
|
||||
if (track !== null) {
|
||||
await this.loadAudioTrack(track, signal);
|
||||
}
|
||||
},
|
||||
|
||||
async loadAudioTrack(track: AudioTrack, signal?: AbortSignal) {
|
||||
if (track.loadedIntro && track.loadedLoop) {
|
||||
this.audioTrackError = null;
|
||||
this.audioTrackProgress = 1;
|
||||
this.audioTrackStatus = "ready";
|
||||
return;
|
||||
}
|
||||
|
||||
this.audioTrackStatus = "loading";
|
||||
this.audioTrackProgress = 0;
|
||||
this.audioTrackError = null;
|
||||
track.loadedIntro = null;
|
||||
track.loadedLoop = null;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (
|
||||
const [fileName, setter] of [
|
||||
[track.FileNameIntro, (buffer: AudioBuffer | null) => {
|
||||
track.loadedIntro = buffer;
|
||||
}] as const,
|
||||
[track.FileNameLoop, (buffer: AudioBuffer | null) => {
|
||||
track.loadedLoop = buffer;
|
||||
}] as const,
|
||||
]
|
||||
) {
|
||||
const dir = import.meta.env.BASE_URL + "/MuzikaGromcheAudio";
|
||||
const url = `${dir}/${fileName}`;
|
||||
try {
|
||||
const buffer = await this.fetchAudioBuffer(url, signal);
|
||||
setter(buffer);
|
||||
} catch (err) {
|
||||
errors.push(`Failed to load audio '${url}': ${err}`);
|
||||
setter(null);
|
||||
}
|
||||
this.audioTrackProgress = 0.5;
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
this.audioTrackError = errors.join("; ");
|
||||
this.audioTrackProgress = 1;
|
||||
this.audioTrackStatus = "error";
|
||||
} else {
|
||||
this.audioTrackProgress = 1;
|
||||
this.audioTrackStatus = "ready";
|
||||
}
|
||||
},
|
||||
|
||||
play() {
|
||||
const track = this.currentAudioTrack;
|
||||
if (!track || this.audioTrackStatus !== "ready") return;
|
||||
if (!track.loadedIntro || !track.loadedLoop) return;
|
||||
|
||||
audioEngine.playBuffers(
|
||||
track.loadedIntro,
|
||||
track.loadedLoop,
|
||||
this.playedDuration,
|
||||
);
|
||||
this.isPlaying = true;
|
||||
},
|
||||
|
||||
pause() {
|
||||
audioEngine.pause();
|
||||
// read current position from engine and store it
|
||||
this.playedDuration = audioEngine.getPosition();
|
||||
this.isPlaying = false;
|
||||
},
|
||||
|
||||
togglePlayPause({ shouldBePlaying }: { shouldBePlaying?: boolean } = {}) {
|
||||
if (shouldBePlaying === undefined) {
|
||||
shouldBePlaying = !this.isPlaying;
|
||||
} else if (shouldBePlaying === this.isPlaying) {
|
||||
return;
|
||||
}
|
||||
if (shouldBePlaying) {
|
||||
this.play();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
audioEngine.shutdown();
|
||||
this.playedDuration = 0;
|
||||
this.isPlaying = false;
|
||||
},
|
||||
|
||||
rewindToIntro() {
|
||||
this.pause();
|
||||
this.playedDuration = 0;
|
||||
},
|
||||
|
||||
rewindToWindUp() {
|
||||
this.pause();
|
||||
// let target = 0;
|
||||
// if (this.currentAudioTrack) {
|
||||
const preWindUpGap = 3; // seconds
|
||||
// // toggle between exact wind-up moment and a short build-up before that.
|
||||
// const current = this.playedDuration;
|
||||
// const exactWindUp = this.currentAudioTrack.WindUpTimer;
|
||||
// const beforeWindUp = exactWindUp - preWindUpGap;
|
||||
// target = current !== beforeWindUp ? beforeWindUp : exactWindUp;
|
||||
// console.log("AAAAAAA", current, exactWindUp, beforeWindUp, current !== beforeWindUp, target);
|
||||
// }
|
||||
// this.playedDuration = target;
|
||||
this.playedDuration = this.currentAudioTrack ? this.currentAudioTrack.WindUpTimer - preWindUpGap : 0;
|
||||
},
|
||||
|
||||
rewindToLoop() {
|
||||
this.pause();
|
||||
const t = this.currentAudioTrack;
|
||||
this.playedDuration = t ? introWithLoopOffsetDurationSeconds(t) : 0;
|
||||
},
|
||||
|
||||
// Delegate fetching/decoding to AudioEngine (it has caching)
|
||||
async fetchAudioBuffer(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AudioBuffer> {
|
||||
return await audioEngine.fetchAudioBuffer(url, signal);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
@import "tailwindcss" prefix(tw);
|
||||
@import "./reset.css";
|
||||
|
||||
* {
|
||||
--main-background-color: #28282e;
|
||||
--inactive-text-color: #909090;
|
||||
--active-text-color: #ffffff;
|
||||
|
||||
--view-separator-color: #090909;
|
||||
--view-separator-border: 1px solid var(--view-separator-color);
|
||||
|
||||
--header-background-color: #17181a;
|
||||
--toolbar-background-color: #212126;
|
||||
--view-background-color: #212126;
|
||||
--card-background-color: #2a2a2d;
|
||||
--card-border-color: #000000;
|
||||
--card-border-width: 1px;
|
||||
--card-border-radius: 4px;
|
||||
--card-border: var(--card-border-width) solid var(--card-border-color);
|
||||
--card-separator-color: #212126;
|
||||
--card-separator-width: 2px;
|
||||
--card-outline-color: #929292;
|
||||
--card-outline-selected-color: #fa5b4a;
|
||||
--card-min-width: 24rem;
|
||||
|
||||
--input-background-color: #1f1f1f;
|
||||
--input-outline-color: #070707;
|
||||
--input-outline-selected-color: #e64b3d;
|
||||
--input-border-width: 1px;
|
||||
--input-border-radius: 4px;
|
||||
|
||||
--timeline-background-color: var(--main-background-color);
|
||||
--timeline-background-top-color: #18181e;
|
||||
--timeline-border-top-color: var(--view-separator-color);
|
||||
--timeline-header-separator-color: #000000;
|
||||
--timeline-header-tick-edge-color: #2f3036;
|
||||
/*
|
||||
track layout:
|
||||
border-top
|
||||
...track content...
|
||||
border-bottom
|
||||
--- border (separator) ---
|
||||
border-top
|
||||
...track content...
|
||||
border-bottom
|
||||
*/
|
||||
--timeline-track-border-color: #00000080;
|
||||
--timeline-track-border: 1px solid var(--timeline-track-border-color);
|
||||
--timeline-track-border-top-color: #00000033;
|
||||
--timeline-track-border-top: 1px solid var(--timeline-track-border-top-color);
|
||||
--timeline-track-border-bottom-color: #0000003a;
|
||||
--timeline-track-border-bottom: 1px solid var(--timeline-track-border-bottom-color);
|
||||
--timeline-text-color: #909090;
|
||||
--timeline-bar-color: #fffff0;
|
||||
--timeline-bar-opacity: 11%;
|
||||
--timeline-bar-width: 1px;
|
||||
|
||||
--timeline-playhead-color: #e64b3d;
|
||||
|
||||
--timeline-marker-beat-color: #ffffff1c;
|
||||
|
||||
--timeline-clip-border-color: #15151580;
|
||||
--timeline-clip-border-color-inner: #151515;
|
||||
--timeline-clip-border-radius: 4px;
|
||||
|
||||
/*
|
||||
* TODO:
|
||||
* timeline clip selected outline:
|
||||
* inner 1px black
|
||||
* outer 2px red
|
||||
*/
|
||||
--timeline-clip-outline-selected-color: #e64b3d;
|
||||
--timeline-clip-outline-selected-width: 2px;
|
||||
--timeline-clip-outline-selected: var(--timeline-clip-outline-selected-width) solid var(--timeline-clip-outline-selected-color);
|
||||
|
||||
--timeline-clip-color-orange: #eb6e01;
|
||||
--timeline-clip-color-apricot: #ffa833;
|
||||
--timeline-clip-color-yellow: #d4ad1f;
|
||||
--timeline-clip-color-lime: #9fc615;
|
||||
--timeline-clip-color-olive: #5f9921;
|
||||
--timeline-clip-color-green: #448f65;
|
||||
--timeline-clip-color-teal: #019899;
|
||||
--timeline-clip-color-navy: #005278;
|
||||
--timeline-clip-color-blue: #4376a1;
|
||||
--timeline-clip-color-purple: #9972a0;
|
||||
--timeline-clip-color-violet: #d0568d;
|
||||
--timeline-clip-color-pink: #e98cb5;
|
||||
--timeline-clip-color-tan: #b9af97;
|
||||
--timeline-clip-color-beige: #c4a07c;
|
||||
--timeline-clip-color-brown: #996601;
|
||||
--timeline-clip-color-chocolate: #8c5a3f;
|
||||
|
||||
--timeline-clip-label-background-color: #00000099;
|
||||
--timeline-clip-label-border-color: #00000060;
|
||||
|
||||
--timeline-clip-baseline-color: #00000033;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: var(--main-background-color);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.toolbar-background {
|
||||
background-color: var(--toolbar-background-color);
|
||||
}
|
||||
|
||||
.timeline-background {
|
||||
background-color: var(--timeline-background-color);
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card-border {
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: var(--card-border-radius);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-vue";
|
||||
import SearchField from "@/components/SearchField.vue";
|
||||
|
||||
test("default placeholder", async () => {
|
||||
const { getByRole } = render(SearchField);
|
||||
|
||||
const searchBox = getByRole("searchbox");
|
||||
await expect.element(searchBox).toBeInTheDocument();
|
||||
await expect.element(searchBox).toHaveAttribute("placeholder", "Search…");
|
||||
});
|
||||
|
||||
test("custom placeholder", async () => {
|
||||
const customPlaceholder = "Hello there";
|
||||
const { getByRole } = render(SearchField, {
|
||||
props: { placeholder: customPlaceholder, modelValue: "" },
|
||||
});
|
||||
|
||||
const searchBox = getByRole("searchbox");
|
||||
await expect.element(searchBox).toBeInTheDocument();
|
||||
await expect.element(searchBox)
|
||||
.toHaveAttribute("placeholder", customPlaceholder);
|
||||
});
|
||||
|
||||
test("empty search disabled clear button", async () => {
|
||||
const { getByRole } = render(SearchField, { props: { modelValue: "" } });
|
||||
|
||||
const button = getByRole("button", { name: "clear" });
|
||||
await expect.element(button).toBeInTheDocument();
|
||||
await expect.element(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("non-empty search enabled clear button", async () => {
|
||||
const { getByRole } = render(SearchField, { props: { modelValue: "hello" } });
|
||||
|
||||
const button = getByRole("button", { name: "clear" });
|
||||
await expect.element(button).toBeInTheDocument();
|
||||
await expect.element(button).toBeEnabled();
|
||||
});
|
||||
|
||||
test("filling search enables clear button", async () => {
|
||||
const { getByRole } = render(SearchField, { props: { modelValue: "" } });
|
||||
const searchBox = getByRole("searchbox");
|
||||
const button = getByRole("button", { name: "clear" });
|
||||
|
||||
// simulate user typing into the input
|
||||
await searchBox.fill("abc");
|
||||
|
||||
await expect.element(button).toBeEnabled();
|
||||
});
|
||||
|
||||
test("clear button clears model value", async () => {
|
||||
const { getByRole } = render(SearchField, { props: { modelValue: "hello" } });
|
||||
const searchBox = getByRole("searchbox");
|
||||
const button = getByRole("button", { name: "clear" });
|
||||
|
||||
// ensure initial state
|
||||
await expect.element(searchBox).toBeInTheDocument();
|
||||
await expect.element(button).toBeEnabled();
|
||||
|
||||
// click the clear button and assert the input was cleared
|
||||
await button.click();
|
||||
|
||||
// input should reflect cleared model
|
||||
await expect.element(searchBox).toHaveValue("");
|
||||
await expect.element(button).toBeDisabled();
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
|
||||
/* aliasing */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/// <reference types="vitest/config" />
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueDevTools from "vite-plugin-vue-devtools";
|
||||
import svgLoader from "vite-svg-loader";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
svgLoader({
|
||||
svgoConfig: {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
{
|
||||
name: "preset-default",
|
||||
params: {
|
||||
overrides: {
|
||||
// @see https://github.com/svg/svgo/issues/1128
|
||||
removeViewBox: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src/"),
|
||||
},
|
||||
},
|
||||
base: "/muzika-gromche",
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: "camelCaseOnly",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
// environment: 'jsdom',
|
||||
// include tests in `tests/` directory
|
||||
include: [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.test.ts",
|
||||
],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
// https://vitest.dev/config/browser/playwright
|
||||
instances: [
|
||||
{ browser: "firefox" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue