Compare commits
1 Commits
81870ecd47
...
aa489eb305
| Author | SHA1 | Date |
|---|---|---|
|
|
aa489eb305 |
|
|
@ -41,4 +41,4 @@ pnpm run test
|
||||||
pnpm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Use scp, rsync or any other tool to upload content of `dist/` to root@ratijas.me `/var/www/html/muzika-gromche/`.
|
Use scp, rsync or any other tool to upload content of `dist/muzika-gromche/` to root@ratijas.me `/var/www/html/muzika-gromche/`.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<link rel="icon" href="/icon-32.png" sizes="32x32" type="image/png">
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Muzika Gromche — The ultimate Jester party music mod</title>
|
<title>Muzika Gromche — The ultimate Jester party music mod</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "node scripts/generate-icons.js",
|
"build": "vue-tsc -b && vite build",
|
||||||
"build": "npm run prebuild && vue-tsc -b && vite build",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"coverage": "vitest run --coverage",
|
"coverage": "vitest run --coverage",
|
||||||
|
|
@ -14,39 +13,36 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-design-icons/svg": "^0.14.15",
|
"@material-design-icons/svg": "^0.14.15",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.16",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.6.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vitest/browser-playwright": "^4.0.15",
|
"@vitest/browser-playwright": "^4.0.10",
|
||||||
"@vitest/coverage-v8": "4.0.14",
|
"@vitest/coverage-v8": "4.0.10",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"eslint": "~9.39.1",
|
"eslint": "~9.39.1",
|
||||||
"eslint-plugin-vue": "~10.5.1",
|
"eslint-plugin-vue": "~10.5.1",
|
||||||
"sharp": "^0.33.5",
|
|
||||||
"png-to-ico": "^3.0.1",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "npm:rolldown-vite@7.1.14",
|
"vite": "npm:rolldown-vite@7.1.14",
|
||||||
"vite-plugin-vue-devtools": "^8.0.5",
|
"vite-plugin-vue-devtools": "^8.0.3",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vitest": "^4.0.15",
|
"vitest": "^4.0.10",
|
||||||
"vitest-browser-vue": "^2.0.1",
|
"vitest-browser-vue": "^2.0.1",
|
||||||
"vue-tsc": "^3.1.5"
|
"vue-tsc": "^3.1.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.1.14"
|
||||||
},
|
},
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"core-js",
|
"core-js"
|
||||||
"sharp"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
|
@ -1,50 +0,0 @@
|
||||||
import sharp from 'sharp';
|
|
||||||
import toIco from 'png-to-ico';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const sourceIcon = path.resolve(__dirname, '../../icon.png');
|
|
||||||
const outputDir = path.resolve(__dirname, '../public');
|
|
||||||
|
|
||||||
async function generateIcons() {
|
|
||||||
await fs.mkdir(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
// Generate PNGs
|
|
||||||
const sizes = [32, 192, 256];
|
|
||||||
for (const size of sizes) {
|
|
||||||
const outputPath = path.join(outputDir, `icon-${size}.png`);
|
|
||||||
await sharp(sourceIcon)
|
|
||||||
.resize(size, size)
|
|
||||||
.toFile(outputPath);
|
|
||||||
console.log(`Generated ${outputPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate apple-touch-icon
|
|
||||||
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png');
|
|
||||||
await sharp(sourceIcon)
|
|
||||||
.resize(180, 180)
|
|
||||||
.toFile(appleIconPath);
|
|
||||||
console.log(`Generated ${appleIconPath}`);
|
|
||||||
|
|
||||||
// Generate favicon.ico
|
|
||||||
const icoSizes = [16, 24, 32, 48];
|
|
||||||
const buffers = await Promise.all(icoSizes.map(size =>
|
|
||||||
sharp(sourceIcon)
|
|
||||||
.resize(size, size)
|
|
||||||
.png()
|
|
||||||
.toBuffer()
|
|
||||||
));
|
|
||||||
const icoBuffer = await toIco(buffers);
|
|
||||||
const icoPath = path.join(outputDir, 'favicon.ico');
|
|
||||||
await fs.writeFile(icoPath, icoBuffer);
|
|
||||||
console.log(`Generated ${icoPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
generateIcons().catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<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 |
|
|
@ -9,470 +9,66 @@
|
||||||
- exposes getPosition() to read current playback time relative to intro start
|
- exposes getPosition() to read current playback time relative to intro start
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type AudioTrack, useWrapTime, wrapTimeFn } from "@/lib/AudioTrack";
|
|
||||||
import type { Seconds } from "@/lib/units";
|
|
||||||
import {
|
|
||||||
type ConfigurableWindow,
|
|
||||||
tryOnScopeDispose,
|
|
||||||
useRafFn,
|
|
||||||
useThrottleFn,
|
|
||||||
watchImmediate,
|
|
||||||
} from "@vueuse/core";
|
|
||||||
import {
|
|
||||||
type MaybeRefOrGetter,
|
|
||||||
type Ref,
|
|
||||||
shallowRef,
|
|
||||||
toValue,
|
|
||||||
watch,
|
|
||||||
} from "vue";
|
|
||||||
|
|
||||||
export const VOLUME_MAX: number = 1.5;
|
export const VOLUME_MAX: number = 1.5;
|
||||||
|
|
||||||
interface PlayerHandle {
|
|
||||||
/**
|
|
||||||
* The `stop()` method schedules a sound to cease playback at the specified time.
|
|
||||||
*/
|
|
||||||
stop: (when?: Seconds) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioTrackBuffersHandle extends PlayerHandle {
|
|
||||||
/**
|
|
||||||
* Time in AudioContext coordinate system of a moment which lines up with the start of the intro audio buffer.
|
|
||||||
* If the startPosition was greater than zero, this time is already in the past when the function returns.
|
|
||||||
*/
|
|
||||||
readonly introStartTime: Seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start playing intro + loop buffers at given position.
|
|
||||||
*
|
|
||||||
* @returns Handle with introStartTime and stop() method.
|
|
||||||
*/
|
|
||||||
function playAudioTrackBuffers(
|
|
||||||
audioCtx: AudioContext,
|
|
||||||
destinationNode: AudioNode,
|
|
||||||
audioTrack: AudioTrack,
|
|
||||||
/**
|
|
||||||
* Position in seconds from the start of the intro
|
|
||||||
*/
|
|
||||||
startPosition: Seconds = 0,
|
|
||||||
): AudioTrackBuffersHandle {
|
|
||||||
const now = audioCtx.currentTime;
|
|
||||||
|
|
||||||
const introBuffer = audioTrack.loadedIntro!;
|
|
||||||
const loopBuffer = audioTrack.loadedLoop!;
|
|
||||||
|
|
||||||
const introDuration = introBuffer.duration;
|
|
||||||
const loopDuration = loopBuffer.duration;
|
|
||||||
|
|
||||||
const wrapper = wrapTimeFn(audioTrack);
|
|
||||||
startPosition = wrapper(startPosition);
|
|
||||||
|
|
||||||
let currentIntro: AudioBufferSourceNode | null;
|
|
||||||
let currentLoop: AudioBufferSourceNode | null;
|
|
||||||
let introStartTime: Seconds;
|
|
||||||
|
|
||||||
// figure out where to start
|
|
||||||
if (startPosition < introDuration) {
|
|
||||||
// start intro with offset, schedule loop after remaining intro time
|
|
||||||
const introOffset = startPosition;
|
|
||||||
const timeUntilLoop = introDuration - introOffset;
|
|
||||||
|
|
||||||
const introNode = audioCtx.createBufferSource();
|
|
||||||
introNode.buffer = introBuffer;
|
|
||||||
introNode.connect(destinationNode);
|
|
||||||
introNode.start(now, introOffset);
|
|
||||||
|
|
||||||
const loopNode = audioCtx.createBufferSource();
|
|
||||||
loopNode.buffer = loopBuffer;
|
|
||||||
loopNode.loop = true;
|
|
||||||
loopNode.connect(destinationNode);
|
|
||||||
loopNode.start(now + timeUntilLoop, 0);
|
|
||||||
|
|
||||||
currentIntro = introNode;
|
|
||||||
currentLoop = loopNode;
|
|
||||||
introStartTime = now - startPosition;
|
|
||||||
} else {
|
|
||||||
// start directly in loop with proper offset into loop
|
|
||||||
const loopOffset = (startPosition - introDuration) % loopDuration;
|
|
||||||
const loopNode = audioCtx.createBufferSource();
|
|
||||||
loopNode.buffer = loopBuffer;
|
|
||||||
loopNode.loop = true;
|
|
||||||
loopNode.connect(destinationNode);
|
|
||||||
loopNode.start(now, loopOffset);
|
|
||||||
|
|
||||||
currentIntro = null;
|
|
||||||
currentLoop = loopNode;
|
|
||||||
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
|
|
||||||
// introStartTime = now - introDuration - loopOffset;
|
|
||||||
introStartTime = now - startPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop(when?: Seconds) {
|
|
||||||
try {
|
|
||||||
currentIntro?.stop(when);
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
currentLoop?.stop(when);
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
currentIntro = null;
|
|
||||||
currentLoop = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { introStartTime, stop };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
|
|
||||||
playerResult: Omit<T, "stop">;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 25 ms for fade-in/fade-out
|
|
||||||
*/
|
|
||||||
const DEFAULT_FADE_DURATION = 0.025;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap the given player function with a Gain node. Applies fade in effect on start and fade out on stop.
|
|
||||||
*
|
|
||||||
* @returns Handle with introStartTime and stop() method.
|
|
||||||
*/
|
|
||||||
function playWithFadeInOut<T extends PlayerHandle>(
|
|
||||||
audioCtx: AudioContext,
|
|
||||||
destinationNode: AudioNode,
|
|
||||||
player: (destinationNode: AudioNode) => T,
|
|
||||||
/**
|
|
||||||
* Duration of fade in/out in seconds. Fade out extends past the stop() call.
|
|
||||||
*/
|
|
||||||
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
|
|
||||||
): PlayWithFadeInOut<T> {
|
|
||||||
const GAIN_MIN = 0.0001;
|
|
||||||
const GAIN_MAX = 1.0;
|
|
||||||
|
|
||||||
const fadeGain = audioCtx.createGain();
|
|
||||||
fadeGain.connect(destinationNode);
|
|
||||||
fadeGain.gain.value = GAIN_MIN;
|
|
||||||
|
|
||||||
const playerHandle = player(fadeGain);
|
|
||||||
|
|
||||||
// fade in
|
|
||||||
const now = audioCtx.currentTime;
|
|
||||||
const fadeEnd = now + fadeDuration;
|
|
||||||
fadeGain.gain.setValueAtTime(GAIN_MIN, now);
|
|
||||||
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd);
|
|
||||||
|
|
||||||
// TODO: setTimeout to actually stop after `when`?
|
|
||||||
function stop(_when?: Seconds) {
|
|
||||||
// fade out
|
|
||||||
const now = audioCtx.currentTime;
|
|
||||||
const fadeEnd = now + fadeDuration;
|
|
||||||
fadeGain.gain.cancelScheduledValues(now);
|
|
||||||
fadeGain.gain.setValueAtTime(GAIN_MAX, now);
|
|
||||||
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd);
|
|
||||||
|
|
||||||
playerHandle.stop(fadeEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { playerResult: playerHandle, stop };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Properties relates to the state of playback.
|
|
||||||
*/
|
|
||||||
export interface PlaybackState {
|
|
||||||
/**
|
|
||||||
* Readonly reference to whether audio is currently playing.
|
|
||||||
*/
|
|
||||||
readonly isPlaying: Readonly<Ref<boolean>>;
|
|
||||||
/**
|
|
||||||
* Readonly reference to the last remembered start-of-playback position.
|
|
||||||
*
|
|
||||||
* Will only update if stop(rememberPosition=true) or seek() is called.
|
|
||||||
*/
|
|
||||||
readonly startPosition: Readonly<Ref<Seconds>>;
|
|
||||||
/**
|
|
||||||
* Returns current playback position in seconds based on AudioContext time.
|
|
||||||
*
|
|
||||||
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
|
|
||||||
*/
|
|
||||||
getCurrentPosition(): Seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StopOptions {
|
|
||||||
/**
|
|
||||||
* If true, update remembered playback position to current position, otherwise revert to last remembered one.
|
|
||||||
*
|
|
||||||
* Defaults to false.
|
|
||||||
*/
|
|
||||||
rememberPosition?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeekOptions {
|
|
||||||
/**
|
|
||||||
* If scrub is requested, plays a short sample at that position.
|
|
||||||
*
|
|
||||||
* Defaults to false.
|
|
||||||
*/
|
|
||||||
scrub?: boolean;
|
|
||||||
// TODO: optionally keep playing after seeking?
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player controls and properties relates to the state of playback.
|
|
||||||
*/
|
|
||||||
export interface PlayerControls {
|
|
||||||
/**
|
|
||||||
* Start playing audio buffers from the last remembered position.
|
|
||||||
*/
|
|
||||||
play: () => void;
|
|
||||||
/**
|
|
||||||
* Stop playing audio buffers.
|
|
||||||
*
|
|
||||||
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
|
|
||||||
*/
|
|
||||||
stop: (options?: StopOptions) => void;
|
|
||||||
/**
|
|
||||||
* Seek to given position in seconds.
|
|
||||||
*
|
|
||||||
* - Stop the playback.
|
|
||||||
* - If scrub is requested, plays a short sample at that position.
|
|
||||||
*/
|
|
||||||
seek: (position: Seconds, options?: SeekOptions) => void;
|
|
||||||
/**
|
|
||||||
* Properties relates to the state of playback.
|
|
||||||
*/
|
|
||||||
readonly playback: PlaybackState;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
|
|
||||||
}
|
|
||||||
|
|
||||||
function reusableAudioBuffersTrackPlayer(
|
|
||||||
audioCtx: AudioContext,
|
|
||||||
destinationNode: AudioNode,
|
|
||||||
audioTrack: AudioTrack,
|
|
||||||
): ReusableAudioBuffersTrackPlayer {
|
|
||||||
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null;
|
|
||||||
const isPlaying = shallowRef(false);
|
|
||||||
const wrapper = wrapTimeFn(audioTrack);
|
|
||||||
const startPosition = useWrapTime(audioTrack, 0);
|
|
||||||
|
|
||||||
function play() {
|
|
||||||
if (currentHandle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentHandle = playWithFadeInOut(
|
|
||||||
audioCtx,
|
|
||||||
destinationNode,
|
|
||||||
(destinationNode) =>
|
|
||||||
playAudioTrackBuffers(
|
|
||||||
audioCtx,
|
|
||||||
destinationNode,
|
|
||||||
audioTrack,
|
|
||||||
startPosition.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
isPlaying.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop(options?: { rememberPosition?: boolean }) {
|
|
||||||
const {
|
|
||||||
rememberPosition = false,
|
|
||||||
} = options ?? {};
|
|
||||||
|
|
||||||
if (currentHandle) {
|
|
||||||
isPlaying.value = false;
|
|
||||||
|
|
||||||
if (rememberPosition) {
|
|
||||||
startPosition.value = getCurrentPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop and discard current handle
|
|
||||||
currentHandle.stop();
|
|
||||||
currentHandle = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seek(seekPosition: Seconds, options?: SeekOptions) {
|
|
||||||
const {
|
|
||||||
scrub = false,
|
|
||||||
} = options ?? {};
|
|
||||||
|
|
||||||
stop({ rememberPosition: false });
|
|
||||||
|
|
||||||
startPosition.value = seekPosition;
|
|
||||||
|
|
||||||
if (scrub) {
|
|
||||||
doThrottledScrub();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrub is subject to debouncing/throttling, so it doesn't start
|
|
||||||
// playing samples too often before previous ones could stop.
|
|
||||||
const doThrottledScrub = useThrottleFn(() => {
|
|
||||||
// play a short sample at the seeked position
|
|
||||||
const scrubHandle = playWithFadeInOut(
|
|
||||||
audioCtx,
|
|
||||||
destinationNode,
|
|
||||||
(destinationNode) =>
|
|
||||||
playAudioTrackBuffers(
|
|
||||||
audioCtx,
|
|
||||||
destinationNode,
|
|
||||||
audioTrack,
|
|
||||||
startPosition.value,
|
|
||||||
),
|
|
||||||
0.01, // short fade of 10 ms
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
scrubHandle.stop(0.01);
|
|
||||||
}, 80); // stop after N ms
|
|
||||||
}, 80);
|
|
||||||
|
|
||||||
function getCurrentPosition(): Seconds {
|
|
||||||
if (!currentHandle) {
|
|
||||||
return startPosition.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = audioCtx.currentTime -
|
|
||||||
currentHandle.playerResult.introStartTime;
|
|
||||||
|
|
||||||
return wrapper(elapsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
play,
|
|
||||||
stop,
|
|
||||||
seek,
|
|
||||||
playback: {
|
|
||||||
isPlaying,
|
|
||||||
startPosition,
|
|
||||||
getCurrentPosition,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LivePlaybackPositionOptions extends ConfigurableWindow {
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LivePlaybackPositionReturn {
|
|
||||||
stop: () => void;
|
|
||||||
position: Readonly<Ref<Seconds>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLivePlaybackPosition(
|
|
||||||
playback: MaybeRefOrGetter<PlaybackState | null>,
|
|
||||||
options?: LivePlaybackPositionOptions,
|
|
||||||
): LivePlaybackPositionReturn {
|
|
||||||
const cleanups: Function[] = [];
|
|
||||||
const cleanup = () => {
|
|
||||||
cleanups.forEach((fn) => fn());
|
|
||||||
cleanups.length = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPosition = () => {
|
|
||||||
return toValue(playback)?.getCurrentPosition() ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const position = shallowRef<Seconds>(getPosition());
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
position.value = getPosition();
|
|
||||||
};
|
|
||||||
|
|
||||||
const raf = useRafFn(() => {
|
|
||||||
updatePosition();
|
|
||||||
}, {
|
|
||||||
...options,
|
|
||||||
immediate: false,
|
|
||||||
once: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopWatch = watchImmediate(() => [
|
|
||||||
toValue(playback),
|
|
||||||
], ([playback]) => {
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
|
|
||||||
if (!playback) return;
|
|
||||||
|
|
||||||
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
|
|
||||||
if (isPlaying) {
|
|
||||||
raf.resume();
|
|
||||||
} else {
|
|
||||||
raf.pause();
|
|
||||||
updatePosition();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
cleanups.push(watch(playback.startPosition, () => {
|
|
||||||
raf.pause();
|
|
||||||
updatePosition();
|
|
||||||
if (playback.isPlaying.value) {
|
|
||||||
raf.resume();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
cleanups.push(() => raf.pause());
|
|
||||||
});
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
stopWatch();
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
tryOnScopeDispose(cleanup);
|
|
||||||
|
|
||||||
return { stop, position };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function togglePlayStop(
|
|
||||||
player: PlayerControls | null,
|
|
||||||
options?: StopOptions,
|
|
||||||
) {
|
|
||||||
if (!player) return;
|
|
||||||
if (player.playback.isPlaying.value) {
|
|
||||||
player.stop(options);
|
|
||||||
} else {
|
|
||||||
player.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AudioEngine {
|
class AudioEngine {
|
||||||
audioCtx: AudioContext | null = null;
|
audioCtx: AudioContext | null = null;
|
||||||
masterGain: GainNode | null = null; // controlled by UI volume slider
|
masterGain: GainNode | null = null; // controlled by UI volume slider
|
||||||
// fadeGain: GainNode | null = null; // tiny fade to avoid clicks
|
fadeGain: GainNode | null = null; // tiny fade to avoid clicks
|
||||||
|
|
||||||
// cache of decoded buffers by URL
|
// cache of decoded buffers by URL
|
||||||
bufferCache = new Map<string, AudioBuffer>();
|
bufferCache = new Map<string, AudioBuffer>();
|
||||||
|
|
||||||
private _player: Ref<PlayerControls | null> = shallowRef(null);
|
// currently playing nodes
|
||||||
// readonly player: Readonly<Ref<PlayerControls | null>> = this._player;
|
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
|
// settings
|
||||||
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
|
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if (this.shuttingDown) return;
|
||||||
if (this.audioCtx) return;
|
if (this.audioCtx) return;
|
||||||
this.audioCtx =
|
this.audioCtx =
|
||||||
new (window.AudioContext || (window as any).webkitAudioContext)();
|
new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
|
||||||
this.masterGain = this.audioCtx.createGain();
|
this.masterGain = this.audioCtx.createGain();
|
||||||
|
this.fadeGain = this.audioCtx.createGain();
|
||||||
|
|
||||||
// routing: sources -> fadeGain -> masterGain -> destination
|
// routing: sources -> fadeGain -> masterGain -> destination
|
||||||
|
this.fadeGain.connect(this.masterGain);
|
||||||
this.masterGain.connect(this.audioCtx.destination);
|
this.masterGain.connect(this.audioCtx.destination);
|
||||||
|
|
||||||
// default full volume
|
// default full volume
|
||||||
this.masterGain.gain.value = 1;
|
this.masterGain.gain.value = 1;
|
||||||
|
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() {
|
shutdown() {
|
||||||
this.stopPlayer();
|
this.pause({ shutdown: true });
|
||||||
this.audioCtx?.close();
|
|
||||||
this.audioCtx = null;
|
|
||||||
this.masterGain = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchAudioBuffer(
|
async fetchAudioBuffer(
|
||||||
|
|
@ -502,29 +98,145 @@ class AudioEngine {
|
||||||
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05);
|
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
initPlayer(
|
private fadeOutNow(fade = this.fadeDuration) {
|
||||||
audioTrack: AudioTrack,
|
if (!this.audioCtx || !this.fadeGain) return;
|
||||||
): PlayerControls | null {
|
const now = this.audioCtx.currentTime;
|
||||||
this.init();
|
const end = now + fade;
|
||||||
if (!this.audioCtx || !this.masterGain) return null;
|
this.fadeGain.gain.cancelScheduledValues(now);
|
||||||
|
this.fadeGain.gain.setValueAtTime(this.fadeGain.gain.value, now);
|
||||||
this.stopPlayer();
|
this.fadeGain.gain.linearRampToValueAtTime(0.0001, end);
|
||||||
|
|
||||||
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) return null;
|
|
||||||
|
|
||||||
const player = reusableAudioBuffersTrackPlayer(
|
|
||||||
this.audioCtx,
|
|
||||||
this.masterGain,
|
|
||||||
audioTrack,
|
|
||||||
);
|
|
||||||
this._player.value = player;
|
|
||||||
return player;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopPlayer() {
|
private fadeInNow(fade = this.fadeDuration) {
|
||||||
if (this._player.value) {
|
if (!this.audioCtx || !this.fadeGain) return;
|
||||||
this._player.value.stop();
|
const now = this.audioCtx.currentTime;
|
||||||
this._player.value = null;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import type { Px } from "@/lib/units";
|
|
||||||
import { useWeakCache } from "@/lib/useWeakCache";
|
|
||||||
import { type Fn, tryOnScopeDispose, watchImmediate } from "@vueuse/core";
|
|
||||||
import type { MaybeRefOrGetter, Ref } from "vue";
|
|
||||||
import { computed, shallowRef, toValue, triggerRef } from "vue";
|
|
||||||
|
|
||||||
// Result of async computation
|
|
||||||
interface UseWaveform {
|
|
||||||
readonly isDone: Readonly<Ref<boolean>>;
|
|
||||||
readonly peaks: Readonly<Ref<Float32Array>>;
|
|
||||||
stop: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WaveformComputation {
|
|
||||||
readonly isDone: Readonly<Ref<boolean>>;
|
|
||||||
readonly peaks: Readonly<Ref<Float32Array>>;
|
|
||||||
/** Start or continue asynchronous computation. */
|
|
||||||
run: () => void;
|
|
||||||
/** Stops any ongoing asynchronous computation. */
|
|
||||||
stop: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
|
|
||||||
() => new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const WAVEFORM_MIN_WIDTH = 10;
|
|
||||||
|
|
||||||
const emptyComputation: WaveformComputation = {
|
|
||||||
isDone: shallowRef(false),
|
|
||||||
peaks: shallowRef(new Float32Array(0)),
|
|
||||||
run() {},
|
|
||||||
stop() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useWaveform(
|
|
||||||
buffer: MaybeRefOrGetter<AudioBuffer>,
|
|
||||||
width: MaybeRefOrGetter<Px>,
|
|
||||||
): UseWaveform {
|
|
||||||
const cleanups: Fn[] = [];
|
|
||||||
const cleanup = () => {
|
|
||||||
cleanups.forEach((fn) => fn());
|
|
||||||
cleanups.length = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation);
|
|
||||||
|
|
||||||
const stopWatch = watchImmediate(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
toValue(buffer),
|
|
||||||
toValue(width),
|
|
||||||
] as const,
|
|
||||||
([b, w]) => {
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
const map = waveformsCache.getOrNew(b);
|
|
||||||
|
|
||||||
if (w < WAVEFORM_MIN_WIDTH) {
|
|
||||||
compRef.value = emptyComputation;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let comp = map.get(w);
|
|
||||||
if (!comp) {
|
|
||||||
comp = useWaveformComputation(b, w);
|
|
||||||
map.set(w, comp);
|
|
||||||
}
|
|
||||||
compRef.value = comp;
|
|
||||||
comp.run();
|
|
||||||
cleanups.push(() => {
|
|
||||||
compRef.value = emptyComputation;
|
|
||||||
comp.stop();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
stopWatch();
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
tryOnScopeDispose(stop);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isDone: computed(() => compRef.value.isDone.value),
|
|
||||||
peaks: computed(() => compRef.value.peaks.value),
|
|
||||||
stop,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const useWaveformComputation = (
|
|
||||||
buffer: AudioBuffer,
|
|
||||||
width: Px,
|
|
||||||
): WaveformComputation => {
|
|
||||||
// How many times run() has been called without stop().
|
|
||||||
// This whole computation should not stop until there is at least one user out there.
|
|
||||||
let users = 0;
|
|
||||||
|
|
||||||
// How many pixels of `width` have been processed so far
|
|
||||||
let progress = 0;
|
|
||||||
|
|
||||||
// Waveform data, length shall be equal to the requested width
|
|
||||||
const waveform = new Float32Array(width);
|
|
||||||
|
|
||||||
const isDone = shallowRef(false);
|
|
||||||
const peaks = shallowRef(waveform);
|
|
||||||
|
|
||||||
const nChannels = buffer.numberOfChannels;
|
|
||||||
|
|
||||||
const samplesPerPx = buffer.length / width;
|
|
||||||
const blocksPerChannel: Float32Array<ArrayBuffer>[] = [];
|
|
||||||
for (let channel = 0; channel < nChannels; channel++) {
|
|
||||||
blocksPerChannel[channel] = new Float32Array(Math.ceil(samplesPerPx));
|
|
||||||
}
|
|
||||||
|
|
||||||
const areWeDoneYet = () => progress >= width;
|
|
||||||
|
|
||||||
function stepBlock() {
|
|
||||||
const blockStart = Math.floor(progress * samplesPerPx);
|
|
||||||
const blockEnd = Math.floor((progress + 1) * samplesPerPx);
|
|
||||||
const blockSize = blockEnd - blockStart;
|
|
||||||
|
|
||||||
for (let channel = 0; channel < nChannels; channel++) {
|
|
||||||
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
waveform[progress] = compressBlock(blocksPerChannel, blockSize);
|
|
||||||
progress += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepBatchOfBlocks() {
|
|
||||||
// run blocks for up to ~10ms to keep UI responsive
|
|
||||||
const start = performance.now();
|
|
||||||
const progressStart = progress;
|
|
||||||
while (!areWeDoneYet()) {
|
|
||||||
stepBlock();
|
|
||||||
if (performance.now() - start >= 10 || progress - progressStart > 100) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerRef(peaks);
|
|
||||||
// triggerRef may as well not trigger refs
|
|
||||||
// https://github.com/vuejs/core/issues/9579
|
|
||||||
// Combined with a throttled drawing function,
|
|
||||||
// this is a slightly better-than-worse workaround.
|
|
||||||
peaks.value = new Float32Array(0);
|
|
||||||
peaks.value = waveform;
|
|
||||||
|
|
||||||
if (areWeDoneYet()) {
|
|
||||||
isDone.value = true;
|
|
||||||
timeoutID = NaN;
|
|
||||||
} else {
|
|
||||||
timeoutID = setTimeout(stepBatchOfBlocks, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutID: number = NaN;
|
|
||||||
|
|
||||||
return {
|
|
||||||
isDone,
|
|
||||||
peaks,
|
|
||||||
run() {
|
|
||||||
users += 1;
|
|
||||||
|
|
||||||
if (Number.isNaN(timeoutID) && users === 1) {
|
|
||||||
timeoutID = setTimeout(stepBatchOfBlocks, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
users -= 1;
|
|
||||||
|
|
||||||
if (!Number.isNaN(timeoutID) && users === 0) {
|
|
||||||
window.clearTimeout(timeoutID);
|
|
||||||
timeoutID = NaN;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function compressBlock(channels: Float32Array[], blockSize: number): number {
|
|
||||||
let peak = 0.0;
|
|
||||||
|
|
||||||
for (let i = 0; i < blockSize; i++) {
|
|
||||||
for (let channel = 0; channel < channels.length; channel++) {
|
|
||||||
peak = Math.max(peak, Math.abs(channels[channel]![i]!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return peak;
|
|
||||||
}
|
|
||||||
|
|
@ -26,6 +26,20 @@ function clear() {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<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 {
|
.button {
|
||||||
color: #929292;
|
color: #929292;
|
||||||
transition: color 150ms linear;
|
transition: color 150ms linear;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
|
||||||
import { useTrackStore } from '@/store/TrackStore';
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
|
||||||
const trackStore = useTrackStore();
|
const trackStore = useTrackStore();
|
||||||
const timeline = useTimelineStore();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -25,7 +23,7 @@ const timeline = useTimelineStore();
|
||||||
Track progress: {{ trackStore.audioTrackProgress }}
|
Track progress: {{ trackStore.audioTrackProgress }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Playback status: {{ timeline.isPlaying }}
|
Playback status: {{ trackStore.isPlaying }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import ScrollablePanel from "@/components/library/panel/ScrollablePanel.vue";
|
|
||||||
import Construction from "@material-design-icons/svg/round/construction.svg";
|
|
||||||
import { computed, shallowRef } from "vue";
|
|
||||||
import AudioTrack from "./views/AudioTrack.vue";
|
|
||||||
import { useTimelineStore } from "@/store/TimelineStore";
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
|
|
||||||
// TODO: use selection (inspector?) manager
|
|
||||||
const selection = shallowRef<object | null>({});
|
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
|
||||||
const { audioTrack, tracksMap } = storeToRefs(timeline);
|
|
||||||
|
|
||||||
const introClip = computed(() => tracksMap.value.intro.clips[0]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- inspector panel -->
|
|
||||||
<ScrollablePanel class="tw:flex-none tw:min-w-80 tw:max-w-80 tw:border-s">
|
|
||||||
|
|
||||||
<template #toolbar>
|
|
||||||
<h3 class="tw:flex tw:flex-row tw:items-center tw:gap-2 tw:px-4 tw:py-1 tw:select-none">
|
|
||||||
<Construction class="tw:fill-current tw:h-5 tw:w-5 toolbar-icon-shadow" />
|
|
||||||
Inspector
|
|
||||||
</h3>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default>
|
|
||||||
<!-- inspector content -->
|
|
||||||
<div class="tw:px-4 tw:h-full tw:flex tw:flex-col" @click="selection = selection ? {} : {}">
|
|
||||||
|
|
||||||
<!-- nothing to inspect -->
|
|
||||||
<div v-if="!selection"
|
|
||||||
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none">
|
|
||||||
Nothing to inspect
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- inspect selection -->
|
|
||||||
<div v-else class="tw:flex-1 tw:flex tw:flex-col tw:gap-4 tw:py-2 tw:text-xs">
|
|
||||||
<AudioTrack v-if="audioTrack" :audioTrack />
|
|
||||||
<!-- <Clip v-if="introClip" :clip="introClip" /> -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</ScrollablePanel>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Control } from "@/components/inspector/controls";
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { getComponentFor } from "./impl";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: Control;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const view = computed(() => getComponentFor(control));
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component :is="view" :control="control" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Controls } from "../controls";
|
|
||||||
import Control from "./Control.vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
controls,
|
|
||||||
} = defineProps<{
|
|
||||||
controls: Controls;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
|
|
||||||
style="grid-template-columns: 80px minmax(0, 1fr);">
|
|
||||||
<Control v-for="control in controls" :key="control.key" :control />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BaseNamedControl } from "@/components/inspector/controls";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
id,
|
|
||||||
} = defineProps<{
|
|
||||||
control: BaseNamedControl;
|
|
||||||
/**
|
|
||||||
* Input ID for an associated label.
|
|
||||||
*/
|
|
||||||
id?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// TODO: reset function
|
|
||||||
function reset(event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- label -->
|
|
||||||
<label :for="id" class="tw:text-right control-label" :class="{ 'control-label__disabled': control.disabled }"
|
|
||||||
@dblclick="reset">
|
|
||||||
{{ control.name }}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- control -->
|
|
||||||
<slot />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ButtonControl } from "@/components/inspector/controls";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: ButtonControl;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control>
|
|
||||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-start">
|
|
||||||
<button type="button" @click="control.action" :disabled="control.disabled" class="control-button">
|
|
||||||
<component v-if="control.icon" :is="control.icon" class="control-button__icon" />
|
|
||||||
<span class="control-button__text">{{ control.text }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CheckboxControl } from "@/components/inspector/controls";
|
|
||||||
import { useId } from "vue";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: CheckboxControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const id = useId();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control :id>
|
|
||||||
<label class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
|
|
||||||
:class="{ 'control-label__disabled': control.disabled }">
|
|
||||||
<input type="checkbox" v-model="control.ref" :id :disabled="control.disabled" />
|
|
||||||
<component :is="control.icon" class="tw:flex-none tw:w-4 tw:h-4 tw:fill-current tw:self-center" />
|
|
||||||
<span v-if="control.label">
|
|
||||||
{{ control.label }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DropDownControl } from "@/components/inspector/controls";
|
|
||||||
import { useId } from "vue";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: DropDownControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const id = useId();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control :id>
|
|
||||||
<div>
|
|
||||||
<select :id v-model="control.ref.value" :disabled="control.disabled"
|
|
||||||
class="tw:w-full tw:max-w-full control-select">
|
|
||||||
<option v-for="option in control.options" :value="option">
|
|
||||||
{{ option }}
|
|
||||||
<!-- and very long text what gonna happen -->
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { HrControl } from "@/components/inspector/controls";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
control: HrControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:col-span-full tw:py-2">
|
|
||||||
<hr class="tw:w-full" style="color: var(--inspector-section-separator-color);" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BaseControl } from "@/components/inspector/controls";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
control: BaseControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:col-span-full" >Not Implemented: {{ control.kind }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NumberControl } from "@/components/inspector/controls";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
import { useId } from "vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: NumberControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const id = useId();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control :id>
|
|
||||||
<div>
|
|
||||||
<input :id type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
|
||||||
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-20" />
|
|
||||||
</div>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { RangeControl } from "@/components/inspector/controls";
|
|
||||||
import Slider from "@/components/library/Slider.vue";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
import { useId } from "vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: RangeControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const id = useId();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control :id>
|
|
||||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
|
|
||||||
<Slider :id v-model.number="control.ref.value"
|
|
||||||
@update:model-value="(value) => control.ref.value = value ?? control.default" :min="control.min"
|
|
||||||
:max="control.max" :step="0.01" :defaultValue="0" :disabled="control.disabled || control.readonly"
|
|
||||||
class="tw:flex-1 tw:self-end" />
|
|
||||||
<input type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
|
||||||
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-14" />
|
|
||||||
</div>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TextControl } from "@/components/inspector/controls";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: TextControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control>
|
|
||||||
<div>
|
|
||||||
<textarea rows="4" v-model="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
|
||||||
class="tw:w-full tw:max-w-full tw:block tw:resize-none input-text" :class="{ 'tw:font-mono': control.monospace }"
|
|
||||||
spellcheck="false" />
|
|
||||||
</div>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TextControl } from "@/components/inspector/controls";
|
|
||||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
} = defineProps<{
|
|
||||||
control: TextControl;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BaseNamedControlView :control>
|
|
||||||
<div class="input-text">
|
|
||||||
<input type="text" :value="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
|
||||||
class="tw:w-full tw:max-w-full" />
|
|
||||||
</div>
|
|
||||||
</BaseNamedControlView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import type { Component } from "vue";
|
|
||||||
import type { Control } from "..";
|
|
||||||
import ButtonControlView from "./ButtonControlView.vue";
|
|
||||||
import CheckboxControlView from "./CheckboxControlView.vue";
|
|
||||||
import DropDownControlView from "./DropDownControlView.vue";
|
|
||||||
import HrControlView from "./HrControlView.vue";
|
|
||||||
import NotImplementedControlView from "./NotImplementedControlView.vue";
|
|
||||||
import NumberControlView from "./NumberControlView.vue";
|
|
||||||
import RangeControlView from "./RangeControlView.vue";
|
|
||||||
import TextAreaControlView from "./TextAreaControlView.vue";
|
|
||||||
import TextControlView from "./TextControlView.vue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping from `control.kind` to the component that renders it.
|
|
||||||
*/
|
|
||||||
const viewMap: Record<Control["kind"], Component> = {
|
|
||||||
hr: HrControlView,
|
|
||||||
text: TextControlView,
|
|
||||||
textarea: TextAreaControlView,
|
|
||||||
number: NumberControlView,
|
|
||||||
range: RangeControlView,
|
|
||||||
checkbox: CheckboxControlView,
|
|
||||||
dropdown: DropDownControlView,
|
|
||||||
button: ButtonControlView,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map `control.kind` to the component that renders it.
|
|
||||||
*
|
|
||||||
* @returns A Component that expects a single `control` property of the same kind as the one passing into this function.
|
|
||||||
*/
|
|
||||||
export function getComponentFor<T extends Control>(control: T): Component {
|
|
||||||
return viewMap[control.kind] ?? NotImplementedControlView;
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import type { Component, Ref } from "vue";
|
|
||||||
|
|
||||||
export interface BaseControl {
|
|
||||||
/**
|
|
||||||
* Discriminator for different types of controls.
|
|
||||||
*/
|
|
||||||
kind: string;
|
|
||||||
/**
|
|
||||||
* Unique key of the control.
|
|
||||||
*/
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HrControl extends BaseControl {
|
|
||||||
kind: "hr";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseNamedControl extends BaseControl {
|
|
||||||
/**
|
|
||||||
* Control's name, displayed on the left of the control view itself. Double click it to reset.
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* An Icon component to display inline with a label.
|
|
||||||
*/
|
|
||||||
icon?: string | Component;
|
|
||||||
/**
|
|
||||||
* Whether the control is disabled as a whole. Dims the label and implies readonly.
|
|
||||||
*/
|
|
||||||
disabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the value should be allowed to change.
|
|
||||||
*/
|
|
||||||
readonly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseTextControl extends BaseNamedControl {
|
|
||||||
ref: Ref<string>;
|
|
||||||
/** Whether to use monospace font. Defaults to false. */
|
|
||||||
monospace?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextControl extends BaseTextControl {
|
|
||||||
kind: "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextAreaControl extends BaseTextControl {
|
|
||||||
kind: "textarea";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseNumberControl extends BaseNamedControl {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
default: number;
|
|
||||||
ref: Ref<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A range slider accompanied by an input field. */
|
|
||||||
export interface RangeControl extends BaseNumberControl {
|
|
||||||
kind: "range";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A text input field for a number. */
|
|
||||||
export interface NumberControl extends BaseNumberControl {
|
|
||||||
kind: "number";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckboxControl extends BaseNamedControl {
|
|
||||||
kind: "checkbox";
|
|
||||||
/** Optional additional label for the checkbox input */
|
|
||||||
label?: string;
|
|
||||||
ref: Ref<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DropDownControl extends BaseNamedControl {
|
|
||||||
kind: "dropdown";
|
|
||||||
options: readonly string[];
|
|
||||||
ref: Ref<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ButtonControl extends BaseNamedControl {
|
|
||||||
kind: "button";
|
|
||||||
/** Unlike control's name label, this property is text on the button itself. */
|
|
||||||
text: string;
|
|
||||||
/** Called when the button is pressed. */
|
|
||||||
action: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Control =
|
|
||||||
| HrControl
|
|
||||||
| TextControl
|
|
||||||
| TextAreaControl
|
|
||||||
| RangeControl
|
|
||||||
| NumberControl
|
|
||||||
| CheckboxControl
|
|
||||||
| DropDownControl
|
|
||||||
| ButtonControl;
|
|
||||||
|
|
||||||
export type Controls = Control[];
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AudioTrack } from "@/lib/AudioTrack";
|
|
||||||
import * as Easing from "@/lib/easing";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import type { Controls } from "../controls";
|
|
||||||
import ControlsView from "../controls/ControlsView.vue";
|
|
||||||
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
|
||||||
|
|
||||||
const {
|
|
||||||
audioTrack,
|
|
||||||
} = defineProps<{
|
|
||||||
audioTrack: AudioTrack,
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const easing = ref(audioTrack.ColorTransitionEasing);
|
|
||||||
|
|
||||||
const controls: Controls = [
|
|
||||||
{
|
|
||||||
kind: "text",
|
|
||||||
key: "Name",
|
|
||||||
name: "Name",
|
|
||||||
ref: ref(audioTrack.Name),
|
|
||||||
readonly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "text",
|
|
||||||
key: "Artist",
|
|
||||||
name: "Artist",
|
|
||||||
ref: ref(audioTrack.Artist),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "text",
|
|
||||||
key: "Song",
|
|
||||||
name: "Song",
|
|
||||||
ref: ref(audioTrack.Song),
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "checkbox",
|
|
||||||
key: "IsExplicit",
|
|
||||||
name: "Is Explicit",
|
|
||||||
icon: Explicit,
|
|
||||||
ref: ref(audioTrack.IsExplicit),
|
|
||||||
label: "Explicit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "hr",
|
|
||||||
key: "audioTrack.hr.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "range",
|
|
||||||
key: "BeatsOffset",
|
|
||||||
name: "Beats Offset",
|
|
||||||
min: -0.5,
|
|
||||||
max: 0.5,
|
|
||||||
default: 0,
|
|
||||||
ref: ref(audioTrack.BeatsOffset),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "range",
|
|
||||||
key: "LoopOffset",
|
|
||||||
name: "Loop Offset",
|
|
||||||
disabled: true,
|
|
||||||
min: 0,
|
|
||||||
max: 128,
|
|
||||||
default: 0,
|
|
||||||
ref: ref(audioTrack.LoopOffset),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "dropdown",
|
|
||||||
key: "Easing",
|
|
||||||
name: "Easing",
|
|
||||||
readonly: true,
|
|
||||||
ref: easing,
|
|
||||||
options: Easing.allNames,
|
|
||||||
},
|
|
||||||
// TODO: remove
|
|
||||||
// {
|
|
||||||
// kind: "dropdown",
|
|
||||||
// key: "Easing2",
|
|
||||||
// name: "Easing",
|
|
||||||
// readonly: true,
|
|
||||||
// ref: easing,
|
|
||||||
// options: Easing.allNames,
|
|
||||||
// disabled: true,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
kind: "number",
|
|
||||||
key: "LyricsIn",
|
|
||||||
name: "Lyrics In",
|
|
||||||
ref: ref(audioTrack.Lyrics[0]?.[0] ?? 0),
|
|
||||||
min: 0,
|
|
||||||
max: 1000,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "textarea",
|
|
||||||
key: "LyricsText",
|
|
||||||
name: "Lyrics Text",
|
|
||||||
ref: ref(audioTrack.Lyrics[0]?.[1] ?? ""),
|
|
||||||
monospace: true,
|
|
||||||
readonly: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "textarea",
|
|
||||||
key: "Lyrics2",
|
|
||||||
name: "Lyrics2",
|
|
||||||
ref: ref(audioTrack.Lyrics[1]?.[1] ?? ""),
|
|
||||||
monospace: true,
|
|
||||||
disabled: true,
|
|
||||||
readonly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "button",
|
|
||||||
key: "Clear",
|
|
||||||
name: "",
|
|
||||||
text: "Clear",
|
|
||||||
icon: Explicit,
|
|
||||||
action: () => {
|
|
||||||
console.log("Trigger death screen");
|
|
||||||
},
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "button",
|
|
||||||
key: "Clear2",
|
|
||||||
name: "",
|
|
||||||
text: "Trigger death screen",
|
|
||||||
// icon: Explicit,
|
|
||||||
action: () => {
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "number",
|
|
||||||
key: "FadeOutBeat",
|
|
||||||
name: "Fade Out Beat",
|
|
||||||
min: -1000,
|
|
||||||
max: 1000,
|
|
||||||
default: -2,
|
|
||||||
ref: ref(audioTrack.FadeOutBeat),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "number",
|
|
||||||
key: "FadeOutDuration",
|
|
||||||
name: "Fade Out Duration",
|
|
||||||
min: 0,
|
|
||||||
max: 1000,
|
|
||||||
default: 2,
|
|
||||||
ref: ref(audioTrack.FadeOutDuration),
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// kind: "number",
|
|
||||||
// key: "FadeOutDuration2",
|
|
||||||
// name: "Fade Out Duration",
|
|
||||||
// min: 0,
|
|
||||||
// max: 1000,
|
|
||||||
// default: 2,
|
|
||||||
// ref: ref(audioTrack.FadeOutDuration),
|
|
||||||
// readonly: true,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// kind: "number",
|
|
||||||
// key: "FadeOutDuration3",
|
|
||||||
// name: "Fade Out Duration",
|
|
||||||
// min: 0,
|
|
||||||
// max: 1000,
|
|
||||||
// default: 2,
|
|
||||||
// ref: ref(audioTrack.FadeOutDuration),
|
|
||||||
// disabled: true,
|
|
||||||
// readonly: true,
|
|
||||||
// },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:select-none">
|
|
||||||
<h3 class="tw:text-sm">Audio Track</h3>
|
|
||||||
<ControlsView :controls />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -75,7 +75,7 @@ const filteredIsEmpty = computed(() => filteredGroupedSortedTracks.value.length
|
||||||
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
|
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
|
||||||
<p class="tw:text-2xl tw:font-bold">No tracks found</p>
|
<p class="tw:text-2xl tw:font-bold">No tracks found</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else
|
<div v-else 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="
|
class="tw:flex-none tw:grid tw:px-8 tw:pb-8 tw:max-sm:px-4 tw:max-sm:pb-4 tw:gap-4 tw:max-sm:columns-1" style="
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr));
|
||||||
">
|
">
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
/** Slider's orientation */
|
|
||||||
export type Orientation = "horizontal" | "vertical";
|
|
||||||
|
|
@ -1,52 +1,32 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useAttrs, useId } from 'vue';
|
import { computed } from 'vue';
|
||||||
import classes from './ToolBar.module.css';
|
import classes from './ToolBar.module.css';
|
||||||
import type { Orientation } from "./Slider";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
defaultValue,
|
|
||||||
reset,
|
|
||||||
orientation = "horizontal",
|
orientation = "horizontal",
|
||||||
title,
|
defaultValue = undefined,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
min?: number,
|
orientation?: "horizontal" | "vertical",
|
||||||
max?: number,
|
|
||||||
step?: number,
|
|
||||||
defaultValue?: number,
|
defaultValue?: number,
|
||||||
reset?: () => void,
|
|
||||||
orientation?: Orientation,
|
|
||||||
title?: string,
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
const isVertical = computed(() => orientation === 'vertical');
|
||||||
const attrs = useAttrs();
|
|
||||||
|
|
||||||
const isVertical = computed(() => orientation === "vertical");
|
|
||||||
const orient = computed(() => orientation === "vertical" ? "vertical" : null);
|
|
||||||
|
|
||||||
const model = defineModel<number>();
|
const model = defineModel<number>();
|
||||||
|
|
||||||
function dblclickHandler(event: MouseEvent) {
|
function reset(event: MouseEvent) {
|
||||||
if (reset !== undefined) {
|
if (defaultValue !== undefined) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
reset();
|
model.value = defaultValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markersListId = useId();
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<input type="range" :min :max :step v-model.number="model" :orient :title @dblclick="dblclickHandler"
|
<input type="range" v-model.number="model" :orient="isVertical ? 'vertical' : null"
|
||||||
class="slider tw:flex-1 tw:basis-20"
|
class="slider tw:flex-1 tw:basis-20"
|
||||||
:class="[classes.toolbarControl, isVertical ? 'tw:min-h-10 tw:max-h-40' : 'tw:min-w-10 tw:max-w-40']"
|
:class="[classes.toolbarControl, isVertical ? 'tw:min-h-10 tw:max-h-40' : 'tw:min-w-10 tw:max-w-40']"
|
||||||
:list="markersListId" v-bind="attrs" />
|
@dblclick="reset" />
|
||||||
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
|
|
||||||
<datalist :id="markersListId">
|
|
||||||
<option v-if="defaultValue !== undefined" :value="defaultValue"></option>
|
|
||||||
</datalist>
|
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.slider {
|
.slider {
|
||||||
|
|
@ -66,23 +46,7 @@ const markersListId = useId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Idntical section ahead, but Chromium refuses to apply the style if it has multiple selectors */
|
.slider::-webkit-slider-runnable-track,
|
||||||
.slider::-webkit-slider-runnable-track {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
content: ' ';
|
|
||||||
background: #161616;
|
|
||||||
border-style: inset;
|
|
||||||
border-width: 1px;
|
|
||||||
border-top-color: #212125;
|
|
||||||
border-color: #2f2f35;
|
|
||||||
border-bottom-color: #2f2f35;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-track {
|
.slider::-moz-range-track {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|
@ -99,57 +63,29 @@ const markersListId = useId();
|
||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider[orient="vertical"]::-webkit-slider-runnable-track {
|
|
||||||
width: 4px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.slider[orient="vertical"]::-webkit-slider-runnable-track,
|
||||||
.slider[orient="vertical"]::-moz-range-track {
|
.slider[orient="vertical"]::-moz-range-track {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb {
|
.slider::-webkit-slider-thumb,
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(#919191 80%, #212121);
|
|
||||||
|
|
||||||
/* unique to -webkit */
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
margin-top: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider[orient="vertical"]::-webkit-slider-thumb {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-left: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
.slider::-moz-range-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(#919191 80%, #212121);
|
|
||||||
|
|
||||||
/* unique to -moz */
|
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(#919191 80%, #212121);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider:not(:disabled):active::-webkit-slider-thumb {
|
.slider:active::-webkit-slider-thumb,
|
||||||
|
.slider:active::-moz-range-thumb {
|
||||||
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
|
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider:not(:disabled):active::-moz-range-thumb {
|
.slider:focus-visible::-webkit-slider-thumb,
|
||||||
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider:focus-visible::-webkit-slider-thumb {
|
|
||||||
outline: 4px solid #556cc9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider:focus-visible::-moz-range-thumb {
|
.slider:focus-visible::-moz-range-thumb {
|
||||||
outline: 4px solid #556cc9;
|
outline: 4px solid #556cc9;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@reference "tailwindcss";
|
@import "tailwindcss" prefix(tw);
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.tool-button {
|
.tool-button {
|
||||||
|
|
@ -21,11 +21,11 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
|
||||||
@apply flex-none w-12 h-12 rounded-full;
|
@apply tw:flex-none tw:w-12 tw:h-12 tw:rounded-full;
|
||||||
|
|
||||||
@variant hover {
|
@variant hover {
|
||||||
&:not(:disabled) {
|
&:not(:disabled) {
|
||||||
@apply text-gray-300;
|
@apply tw:text-gray-300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,8 +36,8 @@
|
||||||
|
|
||||||
&>svg {
|
&>svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
|
@apply tw:w-12 tw:h-12;
|
||||||
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
|
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
|
||||||
@apply w-12 h-12;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
background-color: #2c2c30;
|
background-color: #2c2c30;
|
||||||
/* will-change: transform; */
|
/* will-change: transform; */
|
||||||
|
|
||||||
@apply flex-none w-4 h-4 rounded-full;
|
@apply tw:flex-none tw:w-4 tw:h-4 tw:rounded-full;
|
||||||
|
|
||||||
@variant hover {
|
@variant hover {
|
||||||
&:not(:disabled) {
|
&:not(:disabled) {
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
|
|
||||||
&>svg {
|
&>svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
@apply w-4 h-4;
|
@apply tw:w-4 tw:h-4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,6 @@ const volumeDisplay = computed<number>({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
|
||||||
volume.value = defaultVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValue = computed(() => toSteps(defaultVolume));
|
const defaultValue = computed(() => toSteps(defaultVolume));
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -66,8 +62,20 @@ const defaultValue = computed(() => toSteps(defaultVolume));
|
||||||
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
|
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
|
||||||
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
|
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
|
||||||
</label>
|
</label>
|
||||||
<Slider :min="0" :max="sliderSteps" :step="1" v-model.number="volumeDisplay" :reset="reset" :defaultValue
|
<Slider min="0" :max="sliderSteps" step="1" v-model.number="volumeDisplay" :defaultValue title="Volume"
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,95 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Slider from '@/components/library/Slider.vue';
|
import Slider from '@/components/library/Slider.vue';
|
||||||
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
|
||||||
import Add from "@material-design-icons/svg/filled/add.svg";
|
import Add from "@material-design-icons/svg/filled/add.svg";
|
||||||
import Remove from "@material-design-icons/svg/filled/remove.svg";
|
import Remove from "@material-design-icons/svg/filled/remove.svg";
|
||||||
import type { Orientation } from "./Slider";
|
import { clamp } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
import ToolButtonSmall from './ToolButtonSmall.vue';
|
import ToolButtonSmall from './ToolButtonSmall.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
axis,
|
|
||||||
orientation = "horizontal",
|
orientation = "horizontal",
|
||||||
|
defaultZoom = undefined,
|
||||||
|
extended = false,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
axis: UseZoomAxis,
|
orientation?: "horizontal" | "vertical",
|
||||||
orientation?: Orientation,
|
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>
|
</script>
|
||||||
<!-- for some reason min-width does not propagate up from Slider -->
|
|
||||||
<template>
|
<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"
|
<div class="tw:px-2 tw:flex tw:items-center tw:gap-2"
|
||||||
:class="orientation == 'vertical' ? 'tw:flex-col' : 'tw:flex-row'">
|
:class="orientation == 'vertical' ? 'tw:flex-col' : 'tw:flex-row'">
|
||||||
|
<ToolButtonSmall :icon="Remove" title="Zoom Out" @click="onButton(-1)" :disabled="zoomSliderValue <= zoomMin" />
|
||||||
<ToolButtonSmall :icon="Remove" title="Zoom Out" @click="axis.zoomOut" :disabled="axis.isAtMin.value" />
|
<Slider :min="zoomMin" :max="zoomMax" :step="zoomStepSlider" v-model.number="zoomSliderValue" :orientation
|
||||||
|
:defaultValue />
|
||||||
<!-- skip :defaultValue="axis.default.discrete.value" because snapping makes dragging to negative values impossible -->
|
<ToolButtonSmall :icon="Add" title="Zoom In" @click="onButton(+1)" :disabled="zoomSliderValue >= zoomMax" />
|
||||||
<Slider :min="axis.min.discrete.value" :max="axis.max.discrete.value" :step="axis.stepSmall.discrete.value"
|
|
||||||
v-model.number="axis.zoom.discrete.value" :orientation :reset="axis.reset" />
|
|
||||||
|
|
||||||
<ToolButtonSmall :icon="Add" title="Zoom In" @click="axis.zoomIn" :disabled="axis.isAtMax.value" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import ToolBar from "./ToolBar.vue";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:h-full tw:bg-(--main-background-color) tw:border-(--view-separator-color) tw:flex tw:flex-col">
|
|
||||||
|
|
||||||
<ToolBar v-if="$slots.toolbar">
|
|
||||||
<slot name="toolbar" />
|
|
||||||
</ToolBar>
|
|
||||||
|
|
||||||
<div class="tw:flex-1 tw:min-h-0">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import Panel from "./Panel.vue";
|
|
||||||
import ShadowedScrollView from "./ShadowedScrollView.vue";
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Panel>
|
|
||||||
<template #toolbar>
|
|
||||||
<slot name="toolbar" />
|
|
||||||
</template>
|
|
||||||
<ShadowedScrollView class="tw:h-full">
|
|
||||||
<slot />
|
|
||||||
</ShadowedScrollView>
|
|
||||||
</Panel>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useInterval, useScroll } from "@vueuse/core";
|
|
||||||
import { useTemplateRef } from "vue";
|
|
||||||
|
|
||||||
const scrollView = useTemplateRef('scrollView');
|
|
||||||
|
|
||||||
const { arrivedState, measure } = useScroll(scrollView);
|
|
||||||
|
|
||||||
// useScroll.arrivedState can get stale,
|
|
||||||
// see: https://github.com/vueuse/vueuse/issues/4265#issuecomment-3618168624
|
|
||||||
// useInterval(2000, {
|
|
||||||
// callback: () => {
|
|
||||||
// console.log("MEASURE");
|
|
||||||
// measure();
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:min-h-0 tw:min-w-0 tw:grid tw:grid-rows-1 tw:grid-cols-1">
|
|
||||||
<!-- scrollable content view -->
|
|
||||||
<div ref="scrollView" class="tw:overflow-scroll tw:min-h-0" style="grid-row: 1; grid-column: 1;">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- bars of scroll shadow, on top of content -->
|
|
||||||
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1; grid-column: 1;">
|
|
||||||
|
|
||||||
<!-- top shadow -->
|
|
||||||
<div class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.top }">
|
|
||||||
<div class="tw:h-4 tw:w-full shadow-bottom"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- bottom shadow -->
|
|
||||||
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.bottom }">
|
|
||||||
<div class="tw:h-4 tw:w-full shadow-top"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- left shadow -->
|
|
||||||
<div class="tw:absolute tw:left-0 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.left }">
|
|
||||||
<div class="tw:w-4 tw:h-full shadow-right"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- right shadow -->
|
|
||||||
<div class="tw:absolute tw:right-4 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.right }">
|
|
||||||
<div class="tw:w-4 tw:h-full shadow-left"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.shadow-top,
|
|
||||||
.shadow-right,
|
|
||||||
.shadow-bottom,
|
|
||||||
.shadow-left {
|
|
||||||
--shadow-darkest: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-top {
|
|
||||||
background-image: linear-gradient(to top, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-right {
|
|
||||||
background-image: linear-gradient(to right, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-bottom {
|
|
||||||
background-image: linear-gradient(to bottom, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-left {
|
|
||||||
background-image: linear-gradient(to left, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="tw:bg-(--toolbar-background-color) tw:border-b tw:border-(--view-separator-color)">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -51,6 +51,5 @@ const viewportSide = computed(() => timeline.viewportSide(positionSeconds));
|
||||||
top: 0;
|
top: 0;
|
||||||
will-change: transform, visibility;
|
will-change: transform, visibility;
|
||||||
/* pointer-events: none; */
|
/* pointer-events: none; */
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
<script setup lang="ts">
|
<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 ZoomSlider from '@/components/library/ZoomSlider.vue';
|
||||||
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
|
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
|
||||||
import Playhead from '@/components/timeline/Playhead.vue';
|
|
||||||
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
|
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
|
||||||
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
|
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
|
||||||
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
|
import { useOptionalWidgetState, type UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
|
||||||
import { useTimelineScrubbing } from "@/lib/useTimelineScrubbing";
|
|
||||||
import { useVeiwportWheel } from '@/lib/useVeiwportWheel';
|
|
||||||
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
|
||||||
import { bindTwoWay, toPx } from '@/lib/vue';
|
import { bindTwoWay, toPx } from '@/lib/vue';
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { DEFAULT_ZOOM_HORIZONTAL, DEFAULT_ZOOM_VERTICAL, useTimelineStore } from '@/store/TimelineStore';
|
||||||
import { useElementBounding, useScroll } from '@vueuse/core';
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
|
import { useElementBounding, useEventListener, useScroll } from '@vueuse/core';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useId, useTemplateRef, watch } from 'vue';
|
import { computed, useId, useTemplateRef, watch } from 'vue';
|
||||||
import TimelineTrackHeader from './TimelineTrackHeader.vue';
|
import TimelineTrackHeader from './TimelineTrackHeader.vue';
|
||||||
import TimelineTrackView from './TimelineTrackView.vue';
|
import TimelineTrackView from './TimelineTrackView.vue';
|
||||||
import TimelineMarkers from './markers/TimelineMarkers.vue';
|
import TimelineMarkers from './markers/TimelineMarkers.vue';
|
||||||
|
|
@ -23,17 +23,97 @@ const {
|
||||||
rightSidebar: UseOptionalWidgetStateReturn,
|
rightSidebar: UseOptionalWidgetStateReturn,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const trackStore = useTrackStore();
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore();
|
||||||
|
|
||||||
|
const audioTrack = computed(() => trackStore.currentAudioTrack!);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
headerHeight, sidebarWidth,
|
headerHeight, sidebarWidth,
|
||||||
|
viewportZoomHorizontal, viewportZoomVertical,
|
||||||
viewportScrollOffsetTop, viewportScrollOffsetLeft,
|
viewportScrollOffsetTop, viewportScrollOffsetLeft,
|
||||||
contentWidthIncludingEmptySpacePx,
|
|
||||||
visibleTracks,
|
|
||||||
} = storeToRefs(timeline);
|
} = storeToRefs(timeline);
|
||||||
// nested composable marked with markRaw
|
|
||||||
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis;
|
// const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 3));
|
||||||
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis;
|
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 timelineScrollGroup = useId();
|
||||||
|
|
||||||
|
|
@ -55,11 +135,36 @@ const {
|
||||||
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop);
|
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop);
|
||||||
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft);
|
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft);
|
||||||
|
|
||||||
useVeiwportWheel(timelineRootElement, {
|
function scrollZoomHandler(event: WheelEvent) {
|
||||||
axisHorizontal: viewportZoomHorizontal,
|
// Note: Math.random() prevents console output history from collapsing same entries.
|
||||||
axisVertical: viewportZoomVertical,
|
// console.log("WHEEEEEL", Math.random().toFixed(3), event.deltaX, event.deltaY, event.target, event);
|
||||||
scrollOffsetLeft: timelineScrollViewOffsetLeft,
|
|
||||||
});
|
// 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
|
// Shift+Z - reset zoom
|
||||||
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
|
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
|
||||||
|
|
@ -67,19 +172,18 @@ onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key ==
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrubbing = useTemplateRef('scrubbing');
|
|
||||||
useTimelineScrubbing(scrubbing);
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
|
<div ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
|
||||||
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
|
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
|
||||||
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
|
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
|
||||||
}">
|
}" style="border-top: var(--view-separator-border);">
|
||||||
|
|
||||||
<!-- top left corner, contains zoom controls -->
|
<!-- top left corner, contains zoom controls -->
|
||||||
<div class="toolbar-background tw:max-w-full tw:flex tw:flex-row tw:flex-nowrap tw:items-center"
|
<div 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);">
|
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);">
|
||||||
<ZoomSlider :axis="viewportZoomHorizontal" class="tw:flex-1" />
|
<ZoomSlider v-model:zoom="viewportZoomHorizontal" :default-zoom="DEFAULT_ZOOM_HORIZONTAL" extended
|
||||||
|
class="tw:flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,9 +202,7 @@ useTimelineScrubbing(scrubbing);
|
||||||
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
|
<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);">
|
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);">
|
||||||
|
|
||||||
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
|
<TimelineHeader />
|
||||||
<TimelineHeader />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
|
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
|
||||||
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
||||||
|
|
@ -108,6 +210,10 @@ useTimelineScrubbing(scrubbing);
|
||||||
|
|
||||||
</ScrollSync>
|
</ScrollSync>
|
||||||
|
|
||||||
|
<!-- TODO -->
|
||||||
|
<!-- <div ref="timeline" class="timeline" @pointerdown="timelinePointerDown" @pointerup="timelinePointerUp"
|
||||||
|
@pointermove="timelinePointerMove" @pointerleave="timelinePointerLeave"> -->
|
||||||
|
|
||||||
<!-- timeline content -->
|
<!-- timeline content -->
|
||||||
<ScrollSync ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
|
<ScrollSync ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
|
||||||
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;">
|
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;">
|
||||||
|
|
@ -149,8 +255,7 @@ useTimelineScrubbing(scrubbing);
|
||||||
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
|
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
|
||||||
style="grid-row: 1 / 3; grid-column: 2;">
|
style="grid-row: 1 / 3; grid-column: 2;">
|
||||||
|
|
||||||
<div class="tw:h-full tw:relative tw:overflow-hidden"
|
<div class="tw:h-full tw:relative tw:overflow-hidden" :style="{ width: timeline.contentWidthPx }">
|
||||||
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }">
|
|
||||||
|
|
||||||
<!-- actuals playback position -->
|
<!-- actuals playback position -->
|
||||||
<Playhead :positionSeconds="timeline.playheadPosition" :knob="true">
|
<Playhead :positionSeconds="timeline.playheadPosition" :knob="true">
|
||||||
|
|
@ -193,12 +298,21 @@ useTimelineScrubbing(scrubbing);
|
||||||
class="toolbar-background tw:size-full tw:min-h-0 tw:py-2 tw:flex tw:flex-col tw:items-center"
|
class="toolbar-background tw:size-full tw:min-h-0 tw:py-2 tw:flex tw:flex-col tw:items-center"
|
||||||
style="grid-row: 2; grid-column: 3; border-left: var(--view-separator-border);">
|
style="grid-row: 2; grid-column: 3; border-left: var(--view-separator-border);">
|
||||||
|
|
||||||
<ZoomSlider :axis="viewportZoomVertical" orientation="vertical" class="tw:w-full tw:min-h-0" />
|
<ZoomSlider v-model:zoom="viewportZoomVertical" orientation="vertical" :default-zoom="DEFAULT_ZOOM_VERTICAL"
|
||||||
|
class="tw:w-full tw:min-h-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* .timeline {
|
||||||
|
background-color: var(--timeline-background-color);
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
overflow: hidden;
|
||||||
|
} */
|
||||||
|
|
||||||
.shadow-top,
|
.shadow-top,
|
||||||
.shadow-right,
|
.shadow-right,
|
||||||
.shadow-bottom,
|
.shadow-bottom,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { togglePlayStop } from '@/audio/AudioEngine';
|
|
||||||
import ToolButton from '@/components/library/ToolButton.vue';
|
import ToolButton from '@/components/library/ToolButton.vue';
|
||||||
import ToolToggle from '@/components/library/ToolToggle.vue';
|
import ToolToggle from '@/components/library/ToolToggle.vue';
|
||||||
import Timestamp from '@/components/timeline/Timestamp.vue';
|
import Timestamp from '@/components/timeline/Timestamp.vue';
|
||||||
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState';
|
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState';
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg';
|
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg';
|
||||||
import Play from '@material-design-icons/svg/outlined/play_circle.svg';
|
import Play from '@material-design-icons/svg/outlined/play_circle.svg';
|
||||||
import Replay from '@material-design-icons/svg/outlined/replay.svg';
|
import Replay from '@material-design-icons/svg/outlined/replay.svg';
|
||||||
|
|
@ -15,15 +15,13 @@ import { storeToRefs } from 'pinia';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import MasterVolumeSlider from './MasterVolumeSlider.vue';
|
import MasterVolumeSlider from './MasterVolumeSlider.vue';
|
||||||
import Timeline from './Timeline.vue';
|
import Timeline from './Timeline.vue';
|
||||||
import Panel from "@/components/library/panel/Panel.vue";
|
|
||||||
|
|
||||||
|
const trackStore = useTrackStore();
|
||||||
const timeline = useTimelineStore();
|
const timeline = useTimelineStore();
|
||||||
const { audioTrack, isPlaying } = storeToRefs(timeline);
|
const { currentAudioTrack, isPlaying } = storeToRefs(trackStore);
|
||||||
|
|
||||||
const hasLoopOffset = computed(() => audioTrack.value?.LoopOffset !== 0);
|
const hasLoopOffset = computed(() => currentAudioTrack.value?.LoopOffset !== 0);
|
||||||
|
|
||||||
// Questionable thin vertical sidebar on the right, contains vertical zoom slider.
|
|
||||||
// Not sure I want this to remain, so used a boolean flag to hide.
|
|
||||||
const rightSidebar = useOptionalWidgetState({
|
const rightSidebar = useOptionalWidgetState({
|
||||||
visible: useLocalStorage("timeline.rightSidebar.visible", true),
|
visible: useLocalStorage("timeline.rightSidebar.visible", true),
|
||||||
showString: "Show Right Sidebar",
|
showString: "Show Right Sidebar",
|
||||||
|
|
@ -32,47 +30,48 @@ const rightSidebar = useOptionalWidgetState({
|
||||||
});
|
});
|
||||||
|
|
||||||
function rewindToIntro() {
|
function rewindToIntro() {
|
||||||
timeline.rewindToIntro();
|
trackStore.rewindToIntro();
|
||||||
|
syncPlayheadPosition();
|
||||||
}
|
}
|
||||||
function rewindToWindUp() {
|
function rewindToWindUp() {
|
||||||
timeline.rewindToWindUp();
|
trackStore.rewindToWindUp();
|
||||||
|
syncPlayheadPosition();
|
||||||
}
|
}
|
||||||
function rewindToLoop() {
|
function rewindToLoop() {
|
||||||
timeline.rewindToLoop();
|
trackStore.rewindToLoop();
|
||||||
|
syncPlayheadPosition();
|
||||||
}
|
}
|
||||||
function toggle() {
|
function syncPlayheadPosition() {
|
||||||
togglePlayStop(timeline.player, { rememberPosition: true });
|
timeline.playheadPosition = trackStore.playedDuration;
|
||||||
|
timeline.ensurePlayheadWithinViewport();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Panel class="tw:border-t">
|
<div class="tw:flex tw:flex-col toolbar-background" style="border-top: var(--view-separator-border);">
|
||||||
<template #toolbar>
|
<div
|
||||||
|
class="tw:flex tw:flex-row tw:max-sm:flex-col tw:items-center tw:justify-center tw:gap-x-4 tw:gap-y-2 tw:px-4 tw:max-sm:px-2 tw:py-1">
|
||||||
<div
|
<div
|
||||||
class="tw:flex tw:flex-row tw:max-sm:flex-col tw:items-center tw:justify-center tw:gap-x-4 tw:gap-y-2 tw:px-4 tw:max-sm:px-2 tw:py-1">
|
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:max-sm:border-b-(--view-separator-color)">
|
||||||
<div
|
<ToolButton :icon="Replay" @click="rewindToIntro" title="Rewind to Intro" />
|
||||||
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:border-(--view-separator-color)">
|
<ToolButton :icon="Restart" @click="rewindToWindUp"
|
||||||
<ToolButton :icon="Replay" @click="rewindToIntro" title="Rewind to Intro" />
|
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
|
||||||
<ToolButton :icon="Restart" @click="rewindToWindUp"
|
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
|
||||||
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
|
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'"
|
||||||
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
|
@click="trackStore.togglePlayPause()" />
|
||||||
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'" @click="toggle" />
|
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
|
||||||
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
|
|
||||||
</div>
|
|
||||||
<div class="tw:flex-1 tw:max-sm:w-full tw:flex tw:flex-row tw:gap-x-2">
|
|
||||||
<Timestamp :seconds="timeline.playheadPosition" :beats="timeline.playheadPositionLoopOffsetBeats" />
|
|
||||||
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
|
|
||||||
{{ audioTrack?.Name }}
|
|
||||||
</div>
|
|
||||||
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
|
|
||||||
<ToolToggle :checked="rightSidebar.visible.value" :icon="ViewSidebar" @click="rightSidebar.toggle()"
|
|
||||||
:title="rightSidebar.toggleActionString.value" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<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" />
|
||||||
<Timeline class="tw:min-h-0 tw:size-full" :rightSidebar />
|
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
|
||||||
</Panel>
|
{{ 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>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.description {
|
.description {
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import BottomLine from './BottomLine.vue';
|
|
||||||
import AudioWaveform from './AudioWaveform.vue';
|
|
||||||
|
|
||||||
const {
|
|
||||||
track,
|
|
||||||
clip,
|
|
||||||
width,
|
|
||||||
} = defineProps<{
|
|
||||||
track: TimelineTrackData,
|
|
||||||
clip: TimelineClipData,
|
|
||||||
width: number,
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const label = computed(() => timelineClipLabel(track, clip));
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<!-- waveform -->
|
|
||||||
<div v-if="clip.audioBuffer !== undefined" class="waveform-wrapper">
|
|
||||||
<div class="waveform-content tw:overflow-hidden">
|
|
||||||
<AudioWaveform :buffer="clip.audioBuffer" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- clip label -->
|
|
||||||
<div class="label-wrapper">
|
|
||||||
<div class="label-content tw:truncate" :style="{ display: width < 22 ? 'none' : undefined }" :title="label">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- clip line -->
|
|
||||||
<BottomLine />
|
|
||||||
</template>
|
|
||||||
<style scoped>
|
|
||||||
.waveform-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-left: 1px;
|
|
||||||
padding-right: 1px;
|
|
||||||
/* same as bottom line */
|
|
||||||
padding-bottom: calc(var(--tw-spacing) * 5.5 + 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.waveform-content {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding-left: 2px;
|
|
||||||
padding-right: 4px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-content {
|
|
||||||
background-color: var(--timeline-clip-label-background-color);
|
|
||||||
outline: 1px solid var(--timeline-clip-label-border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
text-align: start;
|
|
||||||
max-width: fit-content;
|
|
||||||
padding: 0 2px;
|
|
||||||
font-size: 8pt;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useWaveform } from '@/audio/AudioWaveform';
|
|
||||||
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core';
|
|
||||||
import { shallowRef, useTemplateRef, watchEffect } from 'vue';
|
|
||||||
|
|
||||||
const {
|
|
||||||
buffer,
|
|
||||||
} = defineProps<{
|
|
||||||
buffer: AudioBuffer,
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const canvas = useTemplateRef('canvas');
|
|
||||||
const canvasWidth = shallowRef(0);
|
|
||||||
|
|
||||||
// TODO: only render what's visible on the timeline.
|
|
||||||
// Currently at max zoom canvas may exceed 32_000 px width which browser refuses to render.
|
|
||||||
|
|
||||||
const waveform = useWaveform(() => buffer, canvasWidth);
|
|
||||||
|
|
||||||
const resizeObserver: globalThis.ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => {
|
|
||||||
const c = unrefElement(canvas);
|
|
||||||
if (!c) return;
|
|
||||||
|
|
||||||
const ctx = c.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const entry = entries.filter(entry => entry.target === c)[0];
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
// get the size from the ResizeObserverEntry (contentRect) and handle
|
|
||||||
// devicePixelRatio so the canvas looks sharp on HiDPI screens
|
|
||||||
const rect = entry.contentRect || c.getBoundingClientRect();
|
|
||||||
const cssWidth = rect.width;
|
|
||||||
const cssHeight = rect.height;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
|
|
||||||
// set internal canvas size in device pixels
|
|
||||||
c.width = Math.max(1, Math.round(cssWidth * dpr));
|
|
||||||
c.height = Math.max(1, Math.round(cssHeight * dpr));
|
|
||||||
|
|
||||||
canvasWidth.value = c.width;
|
|
||||||
|
|
||||||
redraw(waveform.isDone.value, waveform.peaks.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let peakHeights = new Uint32Array(0);
|
|
||||||
|
|
||||||
const redraw = useThrottleFn((isDone: boolean, peaks: Float32Array) => {
|
|
||||||
const c = unrefElement(canvas);
|
|
||||||
if (!c) return;
|
|
||||||
|
|
||||||
const ctx = c.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const width = c.width;
|
|
||||||
const halfHeight = Math.floor(c.height / 2);
|
|
||||||
|
|
||||||
if (peakHeights.length != width) {
|
|
||||||
peakHeights = new Uint32Array(width);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = 1.75;
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
// audio tracks are normalized to a peak -14 dBFS, so we need to stretch them up to take up reasonable space
|
|
||||||
const peakHeight = Math.min(1, (peaks[x] ?? 0) * scale);
|
|
||||||
const height = Math.round(peakHeight * halfHeight);
|
|
||||||
peakHeights[x] = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.clearRect(0, 0, c.width, c.height);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffffd8";
|
|
||||||
ctx.strokeStyle = "transparent";
|
|
||||||
|
|
||||||
// fill first, slanted outline next
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
const height = peakHeights[x]!;
|
|
||||||
// draw vertically centered
|
|
||||||
const y = Math.round(halfHeight - height);
|
|
||||||
ctx.fillRect(x, y, 1, height * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// outline
|
|
||||||
ctx.fillStyle = "transparent";
|
|
||||||
ctx.strokeStyle = "#00000080";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
for (const sign of [-1, 1]) {
|
|
||||||
ctx.moveTo(0, peakHeights[0] ?? 0);
|
|
||||||
|
|
||||||
for (let x = 1; x < width; x += 1) {
|
|
||||||
const height = peakHeights[x]!;
|
|
||||||
const y = sign * height + halfHeight;
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// middle line
|
|
||||||
ctx.fillStyle = "#a1a998";
|
|
||||||
ctx.fillRect(0, Math.round(halfHeight), c.width, 1);
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
useResizeObserver(canvas, resizeObserver);
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
redraw(waveform.isDone.value, waveform.peaks.value);
|
|
||||||
}, { flush: 'sync' });
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<canvas ref="canvas" class="tw:size-full">
|
|
||||||
</canvas>
|
|
||||||
</template>
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
|
||||||
|
// import { toPx } from '@/lib/vue';
|
||||||
|
// import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
// import { storeToRefs } from 'pinia';
|
||||||
import Default from './Default.vue';
|
import Default from './Default.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -9,6 +12,9 @@ const {
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData,
|
||||||
width: number,
|
width: number,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// const { trackHeight } = storeToRefs(useTimelineStore());
|
||||||
|
// const color = "#00000080";
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:w-full fade-out-gradient" />
|
<div class="tw:absolute tw:w-full fade-out-gradient" />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
* @module components/timeline/clip/impl
|
* @module components/timeline/clip/impl
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as Audio } from "./Audio.vue";
|
|
||||||
export { default as Default } from "./Default.vue";
|
export { default as Default } from "./Default.vue";
|
||||||
export { default as Empty } from "./Empty.vue";
|
export { default as Empty } from "./Empty.vue";
|
||||||
export { default as FadeOut } from "./FadeOut.vue";
|
export { default as FadeOut } from "./FadeOut.vue";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { TimelineClipData, TimelineTrackData } from "@/lib/Timeline";
|
import type { TimelineClipData, TimelineTrackData } from "@/lib/Timeline";
|
||||||
import type { Component } from "vue";
|
import type { Component } from "vue";
|
||||||
import { Audio, Default, FadeOut, Lyrics, Palette } from "./impl";
|
import { Default, FadeOut, Lyrics, Palette } from "./impl";
|
||||||
|
|
||||||
export interface ClipContentViewProps {
|
export interface ClipContentViewProps {
|
||||||
track: TimelineTrackData;
|
track: TimelineTrackData;
|
||||||
|
|
@ -13,7 +13,7 @@ export type ClipContentViewComponent = Component<ClipContentViewProps>;
|
||||||
export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent {
|
export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent {
|
||||||
switch (track.contentViewType) {
|
switch (track.contentViewType) {
|
||||||
case "audio":
|
case "audio":
|
||||||
return Audio;
|
return Default;
|
||||||
case "event":
|
case "event":
|
||||||
return Default;
|
return Default;
|
||||||
case "fadeout":
|
case "fadeout":
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const {
|
||||||
<div v-for="i in 8" class="tick tick-minor" :style="{ left: `${10 * (i < 5 ? i : i + 1)}%` }" />
|
<div v-for="i in 8" class="tick tick-minor" :style="{ left: `${10 * (i < 5 ? i : i + 1)}%` }" />
|
||||||
<div v-for="i in 10" class="tick tick-patch" :style="{ left: `${10 * i + 5}%` }" />
|
<div v-for="i in 10" class="tick tick-patch" :style="{ left: `${10 * i + 5}%` }" />
|
||||||
|
|
||||||
<span class="tw:absolute tw:left-2 tw:text-xs tw:text-gray-400 tw:select-none label">
|
<span class="tw:absolute tw:left-1 tw:text-xs tw:text-gray-400 tw:select-none label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue';
|
|
||||||
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks';
|
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks';
|
||||||
import { toPx } from '@/lib/vue';
|
import { toPx } from '@/lib/vue';
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
|
|
@ -16,22 +15,15 @@ const allTicks = [
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:max-h-full tw:overflow-hidden" style=""
|
<div class="tw:relative tw:max-h-full tw:overflow-hidden" style="border-bottom: 1px solid #252525;"
|
||||||
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }">
|
:style="{ width: contentWidthIncludingEmptySpacePx, height: toPx(headerHeight) }">
|
||||||
|
|
||||||
<!-- header ticks for seconds and beats-->
|
<!-- header ticks for seconds and beats-->
|
||||||
<div class="tw:absolute tw:size-full" v-for="{ ticks, position } in allTicks">
|
<div class="tw:size-full" v-for="{ ticks, position } in allTicks">
|
||||||
<TickInterval v-for="tick in ticks.ticks.value" :position :left="ticks.left(tick).value"
|
<TickInterval v-for="tick in ticks.ticks.value" :position :left="ticks.left(tick).value"
|
||||||
:width="ticks.widthPx.value" :label="ticks.label(tick).value" />
|
:width="ticks.widthPx.value" :label="ticks.label(tick).value" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tw:absolute tw:size-full tw:border-b tw:border-[#252525]"></div>
|
|
||||||
|
|
||||||
<!-- header markers -->
|
|
||||||
<div class="tw:absolute tw:size-full">
|
|
||||||
<MarkerBox v-for="marker in timeline.markers" :marker />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineMarkerData } from '@/lib/Timeline';
|
import type { TimelineMarkerData } from '@/lib/Timeline';
|
||||||
import { markerToAbsoluteTime } from '@/lib/Timeline';
|
|
||||||
import { usePx } from '@/lib/usePx';
|
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
|
||||||
import { computed, shallowRef, useTemplateRef } from 'vue';
|
|
||||||
import MarkerBoxSvg from "./marker-box.svg";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
marker,
|
marker,
|
||||||
|
|
@ -12,60 +7,12 @@ const {
|
||||||
marker: TimelineMarkerData,
|
marker: TimelineMarkerData,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const left = `15%`;
|
||||||
|
|
||||||
const left = usePx(() => {
|
|
||||||
const seconds = markerToAbsoluteTime(timeline.audioTrack!, marker);
|
|
||||||
const px = timeline.secondsToPixels(seconds)
|
|
||||||
return px;
|
|
||||||
});
|
|
||||||
|
|
||||||
// const referenceClass = computed(() => marker.reference === 'absolute' ? 'marker-box-top' : 'marker-box-bottom');
|
|
||||||
const positionClass = computed(() => marker.position === 'top' ? 'marker-box-top' : 'marker-box-bottom');
|
|
||||||
|
|
||||||
// TODO: selection manager
|
|
||||||
const selected = shallowRef(false);
|
|
||||||
|
|
||||||
function toggle(event: MouseEvent) {
|
|
||||||
selected.value = !selected.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = useTemplateRef('element');
|
|
||||||
|
|
||||||
function onPointerDown(event: PointerEvent) {
|
|
||||||
element.value?.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div ref="element" class="tw:absolute marker-box" :class="[positionClass, { selected }]" :style="{
|
<div class="tw:absolute tw:w-0 tw:h-full tw:border-l" :style="{
|
||||||
left: left.string,
|
left,
|
||||||
color: marker.color,
|
borderColor: marker.color,
|
||||||
}" :title="marker.name" @click.prevent.stop="toggle" @pointerdown.prevent.stop="onPointerDown">
|
}" :label="marker.name" />
|
||||||
<MarkerBoxSvg />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
.marker-box {
|
|
||||||
position: absolute;
|
|
||||||
width: 11px;
|
|
||||||
height: 16px;
|
|
||||||
transform: translateX(-5px) translateY(1px);
|
|
||||||
--marker-stroke-color: #00000080;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-box-bottom {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-box-top {
|
|
||||||
top: 0;
|
|
||||||
|
|
||||||
&:deep(svg) {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected {
|
|
||||||
--marker-stroke-color: #ffffff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimelineMarkerData } from '@/lib/Timeline';
|
import type { TimelineMarkerData } from '@/lib/Timeline';
|
||||||
import { markerToAbsoluteTime } from '@/lib/Timeline';
|
|
||||||
import { usePx } from '@/lib/usePx';
|
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
marker,
|
marker,
|
||||||
|
|
@ -10,19 +7,12 @@ const {
|
||||||
marker: TimelineMarkerData,
|
marker: TimelineMarkerData,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
const left = `${15 + 15 * marker.markerIn}px`;
|
||||||
|
|
||||||
const left = usePx(() => {
|
|
||||||
const seconds = markerToAbsoluteTime(timeline.audioTrack!, marker);
|
|
||||||
const px = timeline.secondsToPixels(seconds)
|
|
||||||
return px;
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:w-0 tw:h-full tw:opacity-60 tw:border-l" :style="{
|
<div class="tw:absolute tw:w-0 tw:h-full tw:border-l" :style="{
|
||||||
left: left.string,
|
left,
|
||||||
borderColor: marker.color,
|
borderColor: marker.color,
|
||||||
}" />
|
}" :label="marker.name" />
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
<!-- Thin colored vertical lines stretching across the timeline, below the clips -->
|
<!-- Thin colored vertical lines stretching across the timeline, below the clips -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { TimelineMarkerData } from "@/lib/Timeline";
|
||||||
import { useTimelineTicksBeats } from '@/lib/useTimelineTicks';
|
import { useTimelineTicksBeats } from '@/lib/useTimelineTicks';
|
||||||
import { useTimelineStore } from "@/store/TimelineStore";
|
|
||||||
import MarkerLine from "./MarkerLine.vue";
|
import MarkerLine from "./MarkerLine.vue";
|
||||||
import TickLine from "./TickLine.vue";
|
import TickLine from "./TickLine.vue";
|
||||||
|
|
||||||
const ticks = useTimelineTicksBeats();
|
const ticks = useTimelineTicksBeats();
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
ticks.ticks.value.map(tick => {
|
||||||
|
tick
|
||||||
|
})
|
||||||
|
|
||||||
|
const marker: TimelineMarkerData = {
|
||||||
|
name: "0",
|
||||||
|
color: "var(--timeline-marker-beat-color)",
|
||||||
|
reference: "loop",
|
||||||
|
markerIn: 0,
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="tw:absolute tw:size-full">
|
<div class="tw:absolute tw:size-full">
|
||||||
<!-- timeline ticks for beats-->
|
<!-- timeline ticks for beats-->
|
||||||
<div class="tw:size-full">
|
<div class="tw:size-full">
|
||||||
<TickLine v-for="tick in ticks.ticks.value" :left="ticks.left(tick).value" />
|
<TickLine v-for="tick in ticks.ticks.value" :left="ticks.left(tick).value" />
|
||||||
<MarkerLine v-for="marker in timeline.markers" :marker />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MarkerLine :marker />
|
||||||
|
<MarkerLine :marker="{ ...marker, name: '1', markerIn: 1 }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<svg viewBox="0 0 11 16" fill="currentColor">
|
|
||||||
<path d="M 3 1 h 5 a 2 2 0 0 1 2 2 v 7.5 l -4.5 4.5 l -4.5 -4.5 V 3 a 2 2 0 0 1 2 -2 Z"
|
|
||||||
stroke="var(--marker-stroke-color)" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 191 B |
|
|
@ -1,10 +1,3 @@
|
||||||
import type { EasingName } from "@/lib/easing";
|
|
||||||
import { modRange } from "@/lib/math";
|
|
||||||
import type { AnyTime, Beats, Seconds } from "@/lib/units";
|
|
||||||
import { useSetterRef } from "@/lib/vue";
|
|
||||||
import { clamp } from "@vueuse/core";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
|
|
||||||
export const LANGUAGES = [
|
export const LANGUAGES = [
|
||||||
"English",
|
"English",
|
||||||
"Russian",
|
"Russian",
|
||||||
|
|
@ -40,27 +33,27 @@ export interface AudioTrack {
|
||||||
Name: string;
|
Name: string;
|
||||||
IsExplicit: boolean;
|
IsExplicit: boolean;
|
||||||
Language: Language;
|
Language: Language;
|
||||||
WindUpTimer: Seconds;
|
WindUpTimer: number;
|
||||||
Bpm: number;
|
Bpm: number;
|
||||||
Beats: Beats;
|
Beats: number;
|
||||||
LoopOffset: Beats;
|
LoopOffset: number;
|
||||||
Ext: string;
|
Ext: string;
|
||||||
FileDurationIntro: Seconds;
|
FileDurationIntro: number;
|
||||||
FileDurationLoop: Seconds;
|
FileDurationLoop: number;
|
||||||
FileNameIntro: string;
|
FileNameIntro: string;
|
||||||
FileNameLoop: string;
|
FileNameLoop: string;
|
||||||
BeatsOffset: Beats;
|
BeatsOffset: number;
|
||||||
|
|
||||||
FadeOutBeat: Beats;
|
FadeOutBeat: number;
|
||||||
FadeOutDuration: Beats;
|
FadeOutDuration: number;
|
||||||
ColorTransitionIn: Beats;
|
ColorTransitionIn: number;
|
||||||
ColorTransitionOut: Beats;
|
ColorTransitionOut: number;
|
||||||
ColorTransitionEasing: EasingName;
|
ColorTransitionEasing: string;
|
||||||
|
|
||||||
FlickerLightsTimeSeries: Beats[];
|
FlickerLightsTimeSeries: number[];
|
||||||
Lyrics: TimeSeries<string>;
|
Lyrics: TimeSeries<string>;
|
||||||
DrunknessLoopOffsetTimeSeries: TimeSeries<Beats>;
|
DrunknessLoopOffsetTimeSeries: TimeSeries<number>;
|
||||||
CondensationLoopOffsetTimeSeries: TimeSeries<Beats>;
|
CondensationLoopOffsetTimeSeries: TimeSeries<number>;
|
||||||
|
|
||||||
Palette: ColorString[];
|
Palette: ColorString[];
|
||||||
GameOverText: string | null;
|
GameOverText: string | null;
|
||||||
|
|
@ -112,9 +105,9 @@ export function dummyAudioTrackForTesting(): AudioTrack {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TimeSeries<T> = [AnyTime, T][];
|
export type TimeSeries<T> = [number, T][];
|
||||||
|
|
||||||
export function timeSeriesIsEmpty(timeSeries: TimeSeries<AnyTime>): boolean {
|
export function timeSeriesIsEmpty(timeSeries: TimeSeries<any>): boolean {
|
||||||
return timeSeries.length !== 0;
|
return timeSeries.length !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,10 +118,7 @@ export interface Codenames {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function formatTime(time: number, precision: number = 3): string {
|
||||||
* Format timeline wall clock timestamp like [-]0:00.000 with configurable milliseconds precision.
|
|
||||||
*/
|
|
||||||
export function formatTime(time: Seconds, precision: number = 3): string {
|
|
||||||
const isNegative = time < 0;
|
const isNegative = time < 0;
|
||||||
const isNegativeString = isNegative ? "-" : "";
|
const isNegativeString = isNegative ? "-" : "";
|
||||||
if (isNegative) {
|
if (isNegative) {
|
||||||
|
|
@ -136,85 +126,50 @@ export function formatTime(time: Seconds, precision: number = 3): string {
|
||||||
}
|
}
|
||||||
const minutes = Math.floor(time / 60);
|
const minutes = Math.floor(time / 60);
|
||||||
const seconds = 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 secondsString = seconds.toString().padStart(2, "0");
|
||||||
|
const millisecondsString = milliseconds.toString().padStart(precision, "0");
|
||||||
precision = clamp(precision, 0, 3);
|
return `${isNegativeString}${minutes}:${secondsString}.${millisecondsString}`;
|
||||||
if (precision === 0) {
|
|
||||||
return `${isNegativeString}${minutes}:${secondsString}`;
|
|
||||||
}
|
|
||||||
const factor = 10 ** precision;
|
|
||||||
const subsecond = Math.floor((time * factor) % factor);
|
|
||||||
const subsecondString = subsecond.toString().padStart(precision, "0");
|
|
||||||
return `${isNegativeString}${minutes}:${secondsString}.${subsecondString}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function formatBeats(beats: number, precision: number = 3): string {
|
||||||
* Format timeline beats timestamp like [-]00.000 with configurable precision for fractional part.
|
|
||||||
*/
|
|
||||||
export function formatBeats(beats: Beats, precision: number = 3): string {
|
|
||||||
const isNegative = beats < 0;
|
const isNegative = beats < 0;
|
||||||
const isNegativeString = isNegative ? "-" : "";
|
const isNegativeString = isNegative ? "-" : "";
|
||||||
if (isNegative) {
|
if (isNegative) {
|
||||||
beats = -beats;
|
beats = -beats;
|
||||||
}
|
}
|
||||||
const integer = Math.floor(beats);
|
const integer = Math.floor(beats);
|
||||||
const integerString = integer.toString().padStart(2, "0");
|
const fractional = Math.floor((beats % 1) * 1000);
|
||||||
|
const integerString = integer.toString().padEnd(2, "0");
|
||||||
precision = clamp(precision, 0, 3);
|
|
||||||
if (precision === 0) {
|
|
||||||
return `${isNegativeString}${integerString}`;
|
|
||||||
}
|
|
||||||
const factor = 10 ** precision;
|
|
||||||
const fractional = Math.floor((beats % 1) * factor);
|
|
||||||
const fractionalString = fractional.toString().padStart(precision, "0");
|
const fractionalString = fractional.toString().padStart(precision, "0");
|
||||||
return `${isNegativeString}${integerString}.${fractionalString}`;
|
return `${isNegativeString}${integerString}.${fractionalString}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function secondsToBeats(track: AudioTrack, seconds: Seconds): Beats {
|
export function secondsToBeats(track: AudioTrack, seconds: number): number {
|
||||||
const percent = seconds / track.FileDurationLoop;
|
const percent = seconds / track.FileDurationLoop;
|
||||||
return percent * track.Beats;
|
return percent * track.Beats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beatsToSeconds(track: AudioTrack, beats: Beats): Seconds {
|
export function beatsToSeconds(track: AudioTrack, beats: number): number {
|
||||||
const percent = beats / track.Beats;
|
const percent = beats / track.Beats;
|
||||||
return percent * track.FileDurationLoop;
|
return percent * track.FileDurationLoop;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Duration of LoopOffset beats converted to seconds. */
|
/** Duration of LoopOffset beats converted to seconds. */
|
||||||
export function loopOffsetSeconds(track: AudioTrack): Seconds {
|
export function loopOffsetSeconds(track: AudioTrack): number {
|
||||||
return beatsToSeconds(track, track.LoopOffset);
|
return beatsToSeconds(track, track.LoopOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Duration of Wind-up Timer plus Loop Offset combined and converted to seconds. */
|
/** Duration of Wind-up Timer plus Loop Offset combined and converted to seconds. */
|
||||||
export function introWithLoopOffsetDurationSeconds(track: AudioTrack): Seconds {
|
export function introWithLoopOffsetDurationSeconds(track: AudioTrack): number {
|
||||||
const { WindUpTimer } = track;
|
const { WindUpTimer } = track;
|
||||||
|
|
||||||
return WindUpTimer + loopOffsetSeconds(track);
|
return WindUpTimer + loopOffsetSeconds(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Duration of Wind-up Timer plus Loop Offset plus one full loop combined and converted to seconds. */
|
/** Duration of Wind-up Timer plus Loop Offset plus one full loop combined and converted to seconds. */
|
||||||
export function totalDurationSeconds(track: AudioTrack): Seconds {
|
export function totalDurationSeconds(track: AudioTrack) {
|
||||||
const { FileDurationLoop } = track;
|
const { FileDurationLoop } = track;
|
||||||
|
|
||||||
return introWithLoopOffsetDurationSeconds(track) + FileDurationLoop;
|
return introWithLoopOffsetDurationSeconds(track) + FileDurationLoop;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapTimeFn(track: AudioTrack): (time: Seconds) => Seconds {
|
|
||||||
const startOfLoop = introWithLoopOffsetDurationSeconds(track);
|
|
||||||
const endOfLoop = totalDurationSeconds(track);
|
|
||||||
return (time: Seconds): Seconds => {
|
|
||||||
return Math.max(0, modRange(time, startOfLoop, endOfLoop));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wrapTime(track: AudioTrack, time: Seconds): Seconds {
|
|
||||||
return wrapTimeFn(track)(time);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWrapTime(
|
|
||||||
track: AudioTrack,
|
|
||||||
initialTime: Seconds,
|
|
||||||
): Ref<Seconds> {
|
|
||||||
const wrapper = wrapTimeFn(track);
|
|
||||||
return useSetterRef<Seconds>(initialTime, wrapper);
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { formatBeats, formatTime } from ".";
|
|
||||||
|
|
||||||
describe("format time", () => {
|
|
||||||
test("baseline", () => {
|
|
||||||
expect(formatTime).toBeDefined();
|
|
||||||
expect(() => formatTime(0)).not.toThrow();
|
|
||||||
});
|
|
||||||
test("default", () => {
|
|
||||||
expect(formatTime(0)).toBe("0:00.000");
|
|
||||||
});
|
|
||||||
test("precision limits", () => {
|
|
||||||
expect(formatTime(0, 0)).toBe("0:00");
|
|
||||||
expect(formatTime(0, 1)).toBe("0:00.0");
|
|
||||||
expect(formatTime(0, 2)).toBe("0:00.00");
|
|
||||||
expect(formatTime(0, 3)).toBe("0:00.000");
|
|
||||||
expect(formatTime(0, 4)).toBe("0:00.000");
|
|
||||||
});
|
|
||||||
test("valid inputs", () => {
|
|
||||||
expect(formatTime(0)).toBe("0:00.000");
|
|
||||||
expect(formatTime(1)).toBe("0:01.000");
|
|
||||||
expect(formatTime(12)).toBe("0:12.000");
|
|
||||||
expect(formatTime(0.001, 3)).toBe("0:00.001");
|
|
||||||
expect(formatTime(0.001, 2)).toBe("0:00.00");
|
|
||||||
expect(formatTime(0.123)).toBe("0:00.123");
|
|
||||||
expect(formatTime(60)).toBe("1:00.000");
|
|
||||||
expect(formatTime(61)).toBe("1:01.000");
|
|
||||||
expect(formatTime(123.456)).toBe("2:03.456");
|
|
||||||
expect(formatTime(-123.456)).toBe("-2:03.456");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe("format beats", () => {
|
|
||||||
test("baseline", () => {
|
|
||||||
expect(formatBeats).toBeDefined();
|
|
||||||
expect(() => formatBeats(0)).not.toThrow();
|
|
||||||
});
|
|
||||||
test("default", () => {
|
|
||||||
expect(formatBeats(0)).toBe("00.000");
|
|
||||||
});
|
|
||||||
test("precision limits", () => {
|
|
||||||
expect(formatBeats(0, 0)).toBe("00");
|
|
||||||
expect(formatBeats(0, 1)).toBe("00.0");
|
|
||||||
expect(formatBeats(0, 2)).toBe("00.00");
|
|
||||||
expect(formatBeats(0, 3)).toBe("00.000");
|
|
||||||
expect(formatBeats(0, 4)).toBe("00.000");
|
|
||||||
});
|
|
||||||
test("minimum padding", () => {
|
|
||||||
expect(formatBeats(0, 0)).toBe("00");
|
|
||||||
expect(formatBeats(1, 0)).toBe("01");
|
|
||||||
expect(formatBeats(10, 0)).toBe("10");
|
|
||||||
expect(formatBeats(20, 0)).toBe("20");
|
|
||||||
expect(formatBeats(100, 0)).toBe("100");
|
|
||||||
});
|
|
||||||
test("valid inputs", () => {
|
|
||||||
expect(formatBeats(0)).toBe("00.000");
|
|
||||||
expect(formatBeats(1)).toBe("01.000");
|
|
||||||
expect(formatBeats(12)).toBe("12.000");
|
|
||||||
expect(formatBeats(0.001, 3)).toBe("00.001");
|
|
||||||
expect(formatBeats(0.001, 2)).toBe("00.00");
|
|
||||||
expect(formatBeats(0.123)).toBe("00.123");
|
|
||||||
expect(formatBeats(61)).toBe("61.000");
|
|
||||||
expect(formatBeats(123.456)).toBe("123.456");
|
|
||||||
expect(formatBeats(-123.456)).toBe("-123.456");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -3,12 +3,10 @@ import {
|
||||||
beatsToSeconds,
|
beatsToSeconds,
|
||||||
type ColorString,
|
type ColorString,
|
||||||
loopOffsetSeconds,
|
loopOffsetSeconds,
|
||||||
secondsToBeats,
|
|
||||||
} from "@/lib/AudioTrack";
|
} from "@/lib/AudioTrack";
|
||||||
import { namedVars as clipColorNamedVars } from "@/lib/colors/clips";
|
import * as namedColors from "@/lib/colors/named-vars";
|
||||||
import { namedVars as markerColorNamedVars } from "@/lib/colors/markers";
|
import { green } from "./colors/named-vars";
|
||||||
import { iterWindowPairs } from "./iter";
|
import { iterWindowPairs } from "./iter";
|
||||||
import type { Beats } from "./units";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference point for all clips on the timeline track.
|
* Reference point for all clips on the timeline track.
|
||||||
|
|
@ -28,7 +26,7 @@ export type ContentViewType =
|
||||||
| "palette"
|
| "palette"
|
||||||
| "text"
|
| "text"
|
||||||
/** Interpolated line between points in time series. */
|
/** Interpolated line between points in time series. */
|
||||||
| "curve";
|
| "curve"
|
||||||
|
|
||||||
export interface MuzikaGromcheTimelineTracksMap {
|
export interface MuzikaGromcheTimelineTracksMap {
|
||||||
intro: TimelineTrackData;
|
intro: TimelineTrackData;
|
||||||
|
|
@ -60,56 +58,56 @@ export function emptyTimelineTracksMap(): MuzikaGromcheTimelineTracksMap {
|
||||||
return {
|
return {
|
||||||
intro: {
|
intro: {
|
||||||
name: "Intro",
|
name: "Intro",
|
||||||
color: clipColorNamedVars.lime,
|
color: namedColors.lime,
|
||||||
reference: "absolute",
|
reference: "absolute",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "audio",
|
contentViewType: "audio",
|
||||||
},
|
},
|
||||||
loop: {
|
loop: {
|
||||||
name: "Loop",
|
name: "Loop",
|
||||||
color: clipColorNamedVars.blue,
|
color: namedColors.blue,
|
||||||
reference: "absolute",
|
reference: "absolute",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "audio",
|
contentViewType: "audio",
|
||||||
},
|
},
|
||||||
flickering: {
|
flickering: {
|
||||||
name: "Flickering",
|
name: "Flickering",
|
||||||
color: clipColorNamedVars.violet,
|
color: namedColors.violet,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "event",
|
contentViewType: "event",
|
||||||
},
|
},
|
||||||
fadeOut: {
|
fadeOut: {
|
||||||
name: "Fade out",
|
name: "Fade out",
|
||||||
color: clipColorNamedVars.chocolate,
|
color: namedColors.chocolate,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "fadeout",
|
contentViewType: "fadeout",
|
||||||
},
|
},
|
||||||
palette: {
|
palette: {
|
||||||
name: "Palette",
|
name: "Palette",
|
||||||
color: clipColorNamedVars.pink,
|
color: namedColors.pink,
|
||||||
reference: "wind-up",
|
reference: "wind-up",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "palette",
|
contentViewType: "palette",
|
||||||
},
|
},
|
||||||
lyrics: {
|
lyrics: {
|
||||||
name: "Lyrics",
|
name: "Lyrics",
|
||||||
color: clipColorNamedVars.tan,
|
color: namedColors.tan,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "text",
|
contentViewType: "text",
|
||||||
},
|
},
|
||||||
drunkness: {
|
drunkness: {
|
||||||
name: "Drunkness",
|
name: "Drunkness",
|
||||||
color: clipColorNamedVars.orange,
|
color: namedColors.orange,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "curve",
|
contentViewType: "curve",
|
||||||
},
|
},
|
||||||
condensation: {
|
condensation: {
|
||||||
name: "Condensation",
|
name: "Condensation",
|
||||||
color: clipColorNamedVars.yellow,
|
color: namedColors.yellow,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
clips: [],
|
clips: [],
|
||||||
contentViewType: "curve",
|
contentViewType: "curve",
|
||||||
|
|
@ -122,31 +120,16 @@ export function generateClips(
|
||||||
): MuzikaGromcheTimelineTracksMap {
|
): MuzikaGromcheTimelineTracksMap {
|
||||||
const tracks = emptyTimelineTracksMap();
|
const tracks = emptyTimelineTracksMap();
|
||||||
|
|
||||||
if (!track.loadedIntro || !track.loadedLoop) return tracks;
|
tracks.intro.clips.push({ clipIn: 0, duration: track.FileDurationIntro });
|
||||||
|
|
||||||
tracks.intro.clips.push({
|
|
||||||
clipIn: 0,
|
|
||||||
duration: track.FileDurationIntro,
|
|
||||||
audioBuffer: track.loadedIntro,
|
|
||||||
});
|
|
||||||
{
|
{
|
||||||
let clipIn = track.FileDurationIntro;
|
let clipIn = track.FileDurationIntro;
|
||||||
tracks.loop.clips.push(
|
tracks.loop.clips.push(
|
||||||
{
|
{ clipIn, duration: track.FileDurationLoop },
|
||||||
clipIn,
|
|
||||||
duration: track.FileDurationLoop,
|
|
||||||
audioBuffer: track.loadedLoop,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
for (let i = 1; i < 10; i++) {
|
for (let i = 1; i < 10; i++) {
|
||||||
let clipIn2 = clipIn + track.FileDurationLoop * i;
|
let clipIn2 = clipIn + track.FileDurationLoop * i;
|
||||||
tracks.loop.clips.push(
|
tracks.loop.clips.push(
|
||||||
{
|
{ clipIn: clipIn2, duration: track.FileDurationLoop, autorepeat: true },
|
||||||
clipIn: clipIn2,
|
|
||||||
duration: track.FileDurationLoop,
|
|
||||||
autorepeat: true,
|
|
||||||
audioBuffer: track.loadedLoop,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +175,7 @@ export interface TimelineTrackData {
|
||||||
color?: string;
|
color?: string;
|
||||||
reference: Reference;
|
reference: Reference;
|
||||||
clips: TimelineClipData[];
|
clips: TimelineClipData[];
|
||||||
contentViewType: ContentViewType;
|
contentViewType?: ContentViewType,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineClipData {
|
export interface TimelineClipData {
|
||||||
|
|
@ -201,8 +184,6 @@ export interface TimelineClipData {
|
||||||
clipIn: number;
|
clipIn: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
autorepeat?: boolean;
|
autorepeat?: boolean;
|
||||||
/** Represented audio buffer, for track.contentViewType === "audio" only */
|
|
||||||
audioBuffer?: AudioBuffer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timelineClipAutorepeat(self: TimelineClipData): boolean {
|
export function timelineClipAutorepeat(self: TimelineClipData): boolean {
|
||||||
|
|
@ -217,7 +198,7 @@ export function timelineClipColor(
|
||||||
track: TimelineTrackData,
|
track: TimelineTrackData,
|
||||||
clip: TimelineClipData,
|
clip: TimelineClipData,
|
||||||
): ColorString {
|
): ColorString {
|
||||||
return clip.color ?? track.color ?? clipColorNamedVars.green;
|
return clip.color ?? track.color ?? green;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timelineClipLabel(
|
export function timelineClipLabel(
|
||||||
|
|
@ -232,88 +213,61 @@ export interface TimelineMarkerData {
|
||||||
color: string;
|
color: string;
|
||||||
reference: Reference;
|
reference: Reference;
|
||||||
markerIn: number;
|
markerIn: number;
|
||||||
/**
|
|
||||||
* Originally intended to be a separation between beats and seconds references.
|
|
||||||
* But since most of the events are in beats coordinate space, let's repurpose it to be marker-defined.
|
|
||||||
*
|
|
||||||
* Defaults to "bottom".
|
|
||||||
*/
|
|
||||||
position?: "top" | "bottom";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateMarkers(track: AudioTrack): TimelineMarkerData[] {
|
export function generateMarkers(track: AudioTrack): TimelineMarkerData[] {
|
||||||
const markers: TimelineMarkerData[] = [];
|
const markers = [];
|
||||||
|
|
||||||
if (track.LoopOffset === 0) {
|
if (track.LoopOffset === 0) {
|
||||||
markers.push({
|
markers.push({
|
||||||
name: "Wind-up Timer & Loop Offset",
|
name: "Wind-up Timer & Loop Offset",
|
||||||
color: markerColorNamedVars.lavender,
|
color: namedColors.purple,
|
||||||
reference: "wind-up",
|
reference: "wind-up",
|
||||||
markerIn: 0,
|
markerIn: 0,
|
||||||
position: "top",
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
markers.push({
|
markers.push({
|
||||||
name: "Wind-up Timer",
|
name: "Wind-up Timer",
|
||||||
color: markerColorNamedVars.lavender,
|
color: namedColors.purple,
|
||||||
reference: "wind-up",
|
reference: "wind-up",
|
||||||
markerIn: 0,
|
markerIn: 0,
|
||||||
position: "top",
|
|
||||||
});
|
});
|
||||||
markers.push({
|
markers.push({
|
||||||
name: "Loop Offset",
|
name: "Loop Offset",
|
||||||
color: markerColorNamedVars.fuchsia,
|
color: namedColors.violet,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
markerIn: 0,
|
markerIn: 0,
|
||||||
position: "top",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
markers.push({
|
markers.push({
|
||||||
name: "End of Loop",
|
name: "End of Loop",
|
||||||
color: markerColorNamedVars.purple,
|
color: namedColors.purple,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
markerIn: track.Beats,
|
markerIn: track.Beats,
|
||||||
position: "top",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const reservedLoopOffsetBeats: Beats[] = [
|
|
||||||
-track.LoopOffset,
|
|
||||||
0,
|
|
||||||
track.Beats,
|
|
||||||
];
|
|
||||||
|
|
||||||
const firstBeat = Math.ceil(-secondsToBeats(track, track.WindUpTimer)) -
|
|
||||||
track.LoopOffset;
|
|
||||||
|
|
||||||
// TODO: i from absolute zero, not wind-up zero
|
// TODO: i from absolute zero, not wind-up zero
|
||||||
for (let i = firstBeat; i < track.Beats; i++) {
|
for (let i = 1; i < track.Beats; i++) {
|
||||||
if (reservedLoopOffsetBeats.includes(i)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (i % 4 === 0) {
|
if (i % 4 === 0) {
|
||||||
// marker on strong beat
|
// marker on strong beat
|
||||||
markers.push({
|
markers.push({
|
||||||
name: `Bar (${i})`,
|
name: "Bar",
|
||||||
color: markerColorNamedVars.blue,
|
color: namedColors.blue,
|
||||||
reference: "loop",
|
reference: "loop",
|
||||||
markerIn: i,
|
markerIn: i,
|
||||||
position: "bottom",
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// regular marker on other beats
|
// regular marker on other beats
|
||||||
if (false) {
|
markers.push({
|
||||||
markers.push({
|
name: "Beat",
|
||||||
name: "Beat",
|
color: namedColors.teal,
|
||||||
color: markerColorNamedVars.cyan,
|
reference: "loop",
|
||||||
reference: "loop",
|
markerIn: i,
|
||||||
markerIn: i,
|
});
|
||||||
position: "bottom",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return markers;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAbsoluteDuration(
|
export function toAbsoluteDuration(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
export const namedHex = {
|
|
||||||
orange: "#eb6e01",
|
|
||||||
apricot: "#ffa833",
|
|
||||||
yellow: "#d4ad1f",
|
|
||||||
lime: "#9fc615",
|
|
||||||
olive: "#5f9921",
|
|
||||||
green: "#448f65",
|
|
||||||
teal: "#019899",
|
|
||||||
navy: "#005278",
|
|
||||||
blue: "#4376a1",
|
|
||||||
purple: "#9972a0",
|
|
||||||
violet: "#d0568d",
|
|
||||||
pink: "#e98cb5",
|
|
||||||
tan: "#b9af97",
|
|
||||||
beige: "#c4a07c",
|
|
||||||
brown: "#996601",
|
|
||||||
chocolate: "#8c5a3f",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const arrayHex = [
|
|
||||||
"#eb6e01",
|
|
||||||
"#ffa833",
|
|
||||||
"#d4ad1f",
|
|
||||||
"#9fc615",
|
|
||||||
"#5f9921",
|
|
||||||
"#448f65",
|
|
||||||
"#019899",
|
|
||||||
"#005278",
|
|
||||||
"#4376a1",
|
|
||||||
"#9972a0",
|
|
||||||
"#d0568d",
|
|
||||||
"#e98cb5",
|
|
||||||
"#b9af97",
|
|
||||||
"#c4a07c",
|
|
||||||
"#996601",
|
|
||||||
"#8c5a3f",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const namedVars = {
|
|
||||||
orange: "var(--timeline-clip-color-orange)",
|
|
||||||
apricot: "var(--timeline-clip-color-apricot)",
|
|
||||||
yellow: "var(--timeline-clip-color-yellow)",
|
|
||||||
lime: "var(--timeline-clip-color-lime)",
|
|
||||||
olive: "var(--timeline-clip-color-olive)",
|
|
||||||
green: "var(--timeline-clip-color-green)",
|
|
||||||
teal: "var(--timeline-clip-color-teal)",
|
|
||||||
navy: "var(--timeline-clip-color-navy)",
|
|
||||||
blue: "var(--timeline-clip-color-blue)",
|
|
||||||
purple: "var(--timeline-clip-color-purple)",
|
|
||||||
violet: "var(--timeline-clip-color-violet)",
|
|
||||||
pink: "var(--timeline-clip-color-pink)",
|
|
||||||
tan: "var(--timeline-clip-color-tan)",
|
|
||||||
beige: "var(--timeline-clip-color-beige)",
|
|
||||||
brown: "var(--timeline-clip-color-brown)",
|
|
||||||
chocolate: "var(--timeline-clip-color-chocolate)",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const arrayVars = [
|
|
||||||
"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;
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
export const namedHex = {
|
|
||||||
blue: "#007fe3",
|
|
||||||
cyan: "#00ced0",
|
|
||||||
green: "#00ad00",
|
|
||||||
yellow: "#f09d00",
|
|
||||||
red: "#e12401",
|
|
||||||
pink: "#ff44c8",
|
|
||||||
purple: "#9013fe",
|
|
||||||
fuchsia: "#c02e6f",
|
|
||||||
rose: "#ffa1b9",
|
|
||||||
lavender: "#a193c8",
|
|
||||||
sky: "#a193c8",
|
|
||||||
mint: "#72db00",
|
|
||||||
lemon: "#dce95a",
|
|
||||||
sand: "#c4915e",
|
|
||||||
cocoa: "#6e5143",
|
|
||||||
cream: "#f5ebe1",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const arrayHex = [
|
|
||||||
"#007fe3",
|
|
||||||
"#00ced0",
|
|
||||||
"#00ad00",
|
|
||||||
"#f09d00",
|
|
||||||
"#e12401",
|
|
||||||
"#ff44c8",
|
|
||||||
"#9013fe",
|
|
||||||
"#c02e6f",
|
|
||||||
"#ffa1b9",
|
|
||||||
"#a193c8",
|
|
||||||
"#a193c8",
|
|
||||||
"#72db00",
|
|
||||||
"#dce95a",
|
|
||||||
"#c4915e",
|
|
||||||
"#6e5143",
|
|
||||||
"#f5ebe1",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const namedVars = {
|
|
||||||
blue: "var(--timeline-marker-color-blue)",
|
|
||||||
cyan: "var(--timeline-marker-color-cyan)",
|
|
||||||
green: "var(--timeline-marker-color-green)",
|
|
||||||
yellow: "var(--timeline-marker-color-yellow)",
|
|
||||||
red: "var(--timeline-marker-color-red)",
|
|
||||||
pink: "var(--timeline-marker-color-pink)",
|
|
||||||
purple: "var(--timeline-marker-color-purple)",
|
|
||||||
fuchsia: "var(--timeline-marker-color-fuchsia)",
|
|
||||||
rose: "var(--timeline-marker-color-rose)",
|
|
||||||
lavender: "var(--timeline-marker-color-lavender)",
|
|
||||||
sky: "var(--timeline-marker-color-sky)",
|
|
||||||
mint: "var(--timeline-marker-color-mint)",
|
|
||||||
lemon: "var(--timeline-marker-color-lemon)",
|
|
||||||
sand: "var(--timeline-marker-color-sand)",
|
|
||||||
cocoa: "var(--timeline-marker-color-cocoa)",
|
|
||||||
cream: "var(--timeline-marker-color-cream)",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const arrayVars = [
|
|
||||||
"var(--timeline-marker-color-blue)",
|
|
||||||
"var(--timeline-marker-color-cyan)",
|
|
||||||
"var(--timeline-marker-color-green)",
|
|
||||||
"var(--timeline-marker-color-yellow)",
|
|
||||||
"var(--timeline-marker-color-red)",
|
|
||||||
"var(--timeline-marker-color-pink)",
|
|
||||||
"var(--timeline-marker-color-purple)",
|
|
||||||
"var(--timeline-marker-color-fuchsia)",
|
|
||||||
"var(--timeline-marker-color-rose)",
|
|
||||||
"var(--timeline-marker-color-lavender)",
|
|
||||||
"var(--timeline-marker-color-sky)",
|
|
||||||
"var(--timeline-marker-color-mint)",
|
|
||||||
"var(--timeline-marker-color-lemon)",
|
|
||||||
"var(--timeline-marker-color-sand)",
|
|
||||||
"var(--timeline-marker-color-cocoa)",
|
|
||||||
"var(--timeline-marker-color-cream)",
|
|
||||||
] 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)";
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import type { Assertion, JestAssertion } from "vitest";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import type { Easing } from ".";
|
|
||||||
import { all, allNames, findEasingByName } from ".";
|
|
||||||
|
|
||||||
type NumberAssertion = {
|
|
||||||
[K in keyof Assertion<number>]: Assertion<number>[K] extends
|
|
||||||
(it: number) => void ? K : never;
|
|
||||||
}[keyof Assertion<number>];
|
|
||||||
|
|
||||||
function expectEasing(
|
|
||||||
easing: Easing,
|
|
||||||
middle: NumberAssertion,
|
|
||||||
): void {
|
|
||||||
expect(easing.eval(0)).toBe(0);
|
|
||||||
// despite the elaborate typing above, it still doesn't work without an as-cast
|
|
||||||
const ass = expect(easing.eval(0.5));
|
|
||||||
(ass[middle] as (it: number) => void)(0.5);
|
|
||||||
expect(easing.eval(1)).toBe(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("easing", () => {
|
|
||||||
test("baseline", () => {
|
|
||||||
expect(all).toBeDefined();
|
|
||||||
expect(allNames).toBeDefined();
|
|
||||||
expect(findEasingByName).toBeDefined();
|
|
||||||
expect(() => findEasingByName("")).not.toThrow();
|
|
||||||
const easing = findEasingByName("");
|
|
||||||
expect(easing).toBeDefined();
|
|
||||||
expect(easing.name).toBe("Linear");
|
|
||||||
expect(easing.eval).toBeDefined();
|
|
||||||
expect(() => easing.eval(0)).not.toThrow();
|
|
||||||
});
|
|
||||||
test("Linear", () => {
|
|
||||||
const easing = findEasingByName("Linear");
|
|
||||||
expectEasing(easing, "toBe");
|
|
||||||
});
|
|
||||||
test("InCubic", () => {
|
|
||||||
const easing = findEasingByName("InCubic");
|
|
||||||
expectEasing(easing, "toBeLessThan");
|
|
||||||
});
|
|
||||||
test("OutCubic", () => {
|
|
||||||
const easing = findEasingByName("OutCubic");
|
|
||||||
expectEasing(easing, "toBeGreaterThan");
|
|
||||||
});
|
|
||||||
test("InOutCubic", () => {
|
|
||||||
const easing = findEasingByName("InOutCubic");
|
|
||||||
expectEasing(easing, "toBe");
|
|
||||||
});
|
|
||||||
test("InExpo", () => {
|
|
||||||
const easing = findEasingByName("InExpo");
|
|
||||||
expectEasing(easing, "toBeLessThan");
|
|
||||||
});
|
|
||||||
test("OutExpo", () => {
|
|
||||||
const easing = findEasingByName("OutExpo");
|
|
||||||
expectEasing(easing, "toBeGreaterThan");
|
|
||||||
});
|
|
||||||
test("InOutExpo", () => {
|
|
||||||
const easing = findEasingByName("InOutExpo");
|
|
||||||
expectEasing(easing, "toBe");
|
|
||||||
});
|
|
||||||
test("find", () => {
|
|
||||||
expect(allNames).toContain("OutExpo");
|
|
||||||
expect(allNames).toContain("InOutCubic");
|
|
||||||
for (const name of allNames) {
|
|
||||||
const easing = findEasingByName(name);
|
|
||||||
expect(easing).toBeDefined();
|
|
||||||
expect(easing.name).toBe(name);
|
|
||||||
expect(all).toContain(easing);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
export interface Easing {
|
|
||||||
readonly name: EasingName;
|
|
||||||
readonly eval: (t: number) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EasingName =
|
|
||||||
| "Linear"
|
|
||||||
| "InCubic"
|
|
||||||
| "OutCubic"
|
|
||||||
| "InOutCubic"
|
|
||||||
| "InExpo"
|
|
||||||
| "OutExpo"
|
|
||||||
| "InOutExpo";
|
|
||||||
|
|
||||||
export const Linear: Easing = {
|
|
||||||
name: "Linear",
|
|
||||||
eval: (x) => x,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InCubic: Easing = {
|
|
||||||
name: "InCubic",
|
|
||||||
eval: (x) => x * x * x,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OutCubic: Easing = {
|
|
||||||
name: "OutCubic",
|
|
||||||
eval: (x) => 1 - Math.pow(1 - x, 3),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InOutCubic: Easing = {
|
|
||||||
name: "InOutCubic",
|
|
||||||
eval: (x) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InExpo: Easing = {
|
|
||||||
name: "InExpo",
|
|
||||||
eval: (x) => x == 0 ? 0 : Math.pow(2, 10 * x - 10),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OutExpo: Easing = {
|
|
||||||
name: "OutExpo",
|
|
||||||
eval: (x) => x == 1 ? 1 : 1 - Math.pow(2, -10 * x),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InOutExpo: Easing = {
|
|
||||||
name: "InOutExpo",
|
|
||||||
eval: (x) =>
|
|
||||||
x == 0
|
|
||||||
? 0
|
|
||||||
: x == 1
|
|
||||||
? 1
|
|
||||||
: x < 0.5
|
|
||||||
? Math.pow(2, 20 * x - 10) / 2
|
|
||||||
: (2 - Math.pow(2, -20 * x + 10)) / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const all: readonly Easing[] = [
|
|
||||||
Linear,
|
|
||||||
InCubic,
|
|
||||||
OutCubic,
|
|
||||||
InOutCubic,
|
|
||||||
InExpo,
|
|
||||||
OutExpo,
|
|
||||||
InOutExpo,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const allNames: readonly EasingName[] = all.map((easing) => easing.name);
|
|
||||||
|
|
||||||
export function findEasingByName(name: EasingName | string): Easing {
|
|
||||||
return all.find((easing) => easing.name == name) ?? Linear;
|
|
||||||
}
|
|
||||||
|
|
@ -28,19 +28,14 @@ function isEditableElement(el?: Element | null): boolean {
|
||||||
const tag = elm.tagName;
|
const tag = elm.tagName;
|
||||||
if (elm.isContentEditable) return true;
|
if (elm.isContentEditable) return true;
|
||||||
// Treat TEXTAREA as editable
|
// Treat TEXTAREA as editable
|
||||||
if (tag === "TEXTAREA") {
|
if (tag === "TEXTAREA") return true;
|
||||||
const input = elm as HTMLTextAreaElement;
|
|
||||||
return !input.disabled && !input.readOnly;
|
|
||||||
}
|
|
||||||
// For INPUT, only consider text-like input types as editable. This
|
// For INPUT, only consider text-like input types as editable. This
|
||||||
// excludes sliders, checkboxes, radio buttons, buttons, file inputs, etc.
|
// excludes sliders, checkboxes, radio buttons, buttons, file inputs, etc.
|
||||||
if (tag === "INPUT") {
|
if (tag === "INPUT") {
|
||||||
const input = elm as HTMLInputElement;
|
const input = elm as HTMLInputElement;
|
||||||
// If no type attribute is present it defaults to 'text'
|
// If no type attribute is present it defaults to 'text'
|
||||||
const type = (input.type || "text").toLowerCase();
|
const type = (input.type || "text").toLowerCase();
|
||||||
if (textLikeTypes.has(type)) {
|
if (textLikeTypes.has(type)) return true;
|
||||||
return !input.disabled && !input.readOnly;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// ARIA text-like roles
|
// ARIA text-like roles
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,5 @@ export type AnyTime = Seconds | Beats;
|
||||||
export type Px = number;
|
export type Px = number;
|
||||||
export type PxString = `${Px}px`;
|
export type PxString = `${Px}px`;
|
||||||
|
|
||||||
/**
|
|
||||||
* zoom raw: suitable for scaling, range 1 .. 7.25
|
|
||||||
*/
|
|
||||||
export type ZoomRaw = number;
|
export type ZoomRaw = number;
|
||||||
/**
|
|
||||||
* zoom discrete: suitable for sliders, range 0 .. 100 or -20 .. 100
|
|
||||||
*/
|
|
||||||
export type ZoomDiscrete = number;
|
export type ZoomDiscrete = number;
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import type { Px, Seconds } from "@/lib/units";
|
|
||||||
import { useTimelineStore } from "@/store/TimelineStore";
|
|
||||||
import {
|
|
||||||
type Arrayable,
|
|
||||||
toArray,
|
|
||||||
tryOnScopeDispose,
|
|
||||||
unrefElement,
|
|
||||||
useEventListener,
|
|
||||||
watchImmediate,
|
|
||||||
} from "@vueuse/core";
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import type { MaybeRefOrGetter } from "vue";
|
|
||||||
import { shallowRef, toValue } from "vue";
|
|
||||||
|
|
||||||
export function useTimelineScrubbing(
|
|
||||||
elements: MaybeRefOrGetter<Arrayable<HTMLElement> | null>,
|
|
||||||
) {
|
|
||||||
const cleanups: Function[] = [];
|
|
||||||
const cleanup = () => {
|
|
||||||
cleanups.forEach((fn) => fn());
|
|
||||||
cleanups.length = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
let wasPlayingBeforeScrubbing = false;
|
|
||||||
const isScrubbing = shallowRef(false);
|
|
||||||
|
|
||||||
const timeline = useTimelineStore();
|
|
||||||
const { player } = storeToRefs(timeline);
|
|
||||||
|
|
||||||
const getTarget = (event: PointerEvent): HTMLElement | null => {
|
|
||||||
const target = event.currentTarget;
|
|
||||||
return target instanceof HTMLElement ? target : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const seek = (event: PointerEvent, scrub: boolean) => {
|
|
||||||
const target = getTarget(event);
|
|
||||||
const p = player.value;
|
|
||||||
if (!target || !p) return;
|
|
||||||
|
|
||||||
// clientX is broken in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=505521#c80
|
|
||||||
const left: Px = event.pageX - target.getBoundingClientRect().left;
|
|
||||||
const seconds: Seconds = timeline.pixelsToSeconds(left);
|
|
||||||
// apply mouse position
|
|
||||||
p.seek(seconds, { scrub });
|
|
||||||
};
|
|
||||||
|
|
||||||
const logTarget = (event: PointerEvent) => {
|
|
||||||
const t = event.currentTarget;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
|
||||||
logTarget(event);
|
|
||||||
|
|
||||||
if (isScrubbing.value) {
|
|
||||||
seek(event, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
|
||||||
logTarget(event);
|
|
||||||
|
|
||||||
const target = getTarget(event);
|
|
||||||
const p = player.value;
|
|
||||||
if (!target || !p) return;
|
|
||||||
|
|
||||||
// Only the first pointer ID is going to be used for scrubbing,
|
|
||||||
// so there should be no conflicts in case of multi-touch input.
|
|
||||||
if (target && !isScrubbing.value) {
|
|
||||||
isScrubbing.value = true;
|
|
||||||
wasPlayingBeforeScrubbing = p.playback.isPlaying.value;
|
|
||||||
target.setPointerCapture(event.pointerId);
|
|
||||||
seek(event, false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onPointerUp = (event: PointerEvent) => {
|
|
||||||
logTarget(event);
|
|
||||||
|
|
||||||
const p = player.value;
|
|
||||||
if (!p) return;
|
|
||||||
|
|
||||||
if (isScrubbing.value) {
|
|
||||||
isScrubbing.value = false;
|
|
||||||
seek(event, false);
|
|
||||||
if (wasPlayingBeforeScrubbing) {
|
|
||||||
wasPlayingBeforeScrubbing = false;
|
|
||||||
p.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onPointerLeave = (_event: PointerEvent) => {
|
|
||||||
};
|
|
||||||
|
|
||||||
const register = (el: EventTarget) => {
|
|
||||||
return [
|
|
||||||
useEventListener(el, "pointerdown", onPointerDown),
|
|
||||||
useEventListener(el, "pointerup", onPointerUp),
|
|
||||||
useEventListener(el, "pointermove", onPointerMove),
|
|
||||||
useEventListener(el, "pointerleave", onPointerLeave),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopWatch = watchImmediate(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
toArray(toValue(elements)).filter((e) => e != null).map((e) =>
|
|
||||||
unrefElement(e)!
|
|
||||||
),
|
|
||||||
] as const,
|
|
||||||
([raw_targets]) => {
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
if (!raw_targets.length) return;
|
|
||||||
|
|
||||||
cleanups.push(
|
|
||||||
...raw_targets.flatMap((el) => register(el)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ flush: "post" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
stopWatch();
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
tryOnScopeDispose(stop);
|
|
||||||
|
|
||||||
return stop;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { formatTime } from "@/lib/AudioTrack";
|
import { formatTime } from "@/lib/AudioTrack";
|
||||||
import { rangeInclusive } from "@/lib/iter";
|
import { rangeInclusive } from "@/lib/iter";
|
||||||
import {
|
import {
|
||||||
|
type AnyTime,
|
||||||
|
type Beats,
|
||||||
|
type Pixels,
|
||||||
|
type Seconds,
|
||||||
useOptimalBeatTickInterval,
|
useOptimalBeatTickInterval,
|
||||||
useOptimalTickInterval,
|
useOptimalTickInterval,
|
||||||
useTicksBounds,
|
useTicksBounds,
|
||||||
|
|
@ -14,7 +18,6 @@ import {
|
||||||
type MaybeRefOrGetter,
|
type MaybeRefOrGetter,
|
||||||
toValue,
|
toValue,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import type { AnyTime, Beats, Px, Seconds } from "./units";
|
|
||||||
|
|
||||||
export interface Ticks<T extends AnyTime> {
|
export interface Ticks<T extends AnyTime> {
|
||||||
tickIn: ComputedRef<T>;
|
tickIn: ComputedRef<T>;
|
||||||
|
|
@ -22,9 +25,9 @@ export interface Ticks<T extends AnyTime> {
|
||||||
interval: ComputedRef<T>;
|
interval: ComputedRef<T>;
|
||||||
/** An inclusive range from tickIn to tickOut, with `interval` step. */
|
/** An inclusive range from tickIn to tickOut, with `interval` step. */
|
||||||
ticks: ComputedRef<T[]>;
|
ticks: ComputedRef<T[]>;
|
||||||
width: ComputedRef<Px>;
|
width: ComputedRef<Pixels>;
|
||||||
widthPx: ComputedRef<string>;
|
widthPx: ComputedRef<string>;
|
||||||
left: (tickIn: T) => ComputedRef<Px>;
|
left: (tickIn: T) => ComputedRef<Pixels>;
|
||||||
label: (tickIn: T) => ComputedRef<string>;
|
label: (tickIn: T) => ComputedRef<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,8 +35,8 @@ export function useTimelineTicks<T extends AnyTime>(
|
||||||
viewportIn: MaybeRefOrGetter<T>,
|
viewportIn: MaybeRefOrGetter<T>,
|
||||||
viewportOut: MaybeRefOrGetter<T>,
|
viewportOut: MaybeRefOrGetter<T>,
|
||||||
interval: ComputedRef<T>,
|
interval: ComputedRef<T>,
|
||||||
intervalToPixels: (interval: T) => Px,
|
intervalToPixels: (interval: T) => Pixels,
|
||||||
positionToPixels: (position: T) => Px,
|
positionToPixels: (position: T) => Pixels,
|
||||||
positionToLabel: (position: T) => string,
|
positionToLabel: (position: T) => string,
|
||||||
): Ticks<T> {
|
): Ticks<T> {
|
||||||
const ticksBounds = useTicksBounds(interval, viewportIn, viewportOut);
|
const ticksBounds = useTicksBounds(interval, viewportIn, viewportOut);
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import type { Px, ZoomRaw } from "@/lib/units";
|
|
||||||
import { useEventListener } from "@vueuse/core";
|
|
||||||
import type { MaybeRefOrGetter, Ref } from "vue";
|
|
||||||
import type { UseZoomAxis } from "../useZoomAxis";
|
|
||||||
|
|
||||||
export interface UseVeiwportWheelOptions {
|
|
||||||
axisHorizontal: UseZoomAxis;
|
|
||||||
axisVertical: UseZoomAxis;
|
|
||||||
scrollOffsetLeft: Ref<Px>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVeiwportWheel(
|
|
||||||
target: MaybeRefOrGetter<HTMLElement | null>,
|
|
||||||
options: UseVeiwportWheelOptions,
|
|
||||||
) {
|
|
||||||
function handler(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;
|
|
||||||
|
|
||||||
// Note: this hardcoded value feels good enough both for discrete mouse wheel and precise touchpad.
|
|
||||||
const ZOOM_RAW_FACTOR = 100;
|
|
||||||
|
|
||||||
if (event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
options.axisVertical.zoom.raw.value -= event.deltaY / ZOOM_RAW_FACTOR;
|
|
||||||
} else if (event.altKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
options.axisHorizontal.zoom.raw.value -= event.deltaY / ZOOM_RAW_FACTOR;
|
|
||||||
} else if (event.ctrlKey && !ignoreCtrlWheel) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
options.scrollOffsetLeft.value += event.deltaY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return useEventListener(target, "wheel", handler, {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
export interface WeakCache<K, V> {
|
|
||||||
getOrNew(k: K): V;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWeakCache<K extends WeakKey, V>(
|
|
||||||
factory: () => V,
|
|
||||||
): WeakCache<K, V> {
|
|
||||||
const cache = new WeakMap<K, V>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
getOrNew(k: K): V {
|
|
||||||
if (cache.has(k)) {
|
|
||||||
return cache.get(k)!;
|
|
||||||
}
|
|
||||||
const v = factory();
|
|
||||||
cache.set(k, v);
|
|
||||||
return v;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { computed, nextTick, shallowRef } from "vue";
|
import { computed, nextTick, shallowRef } from "vue";
|
||||||
import type { UseZoomAxis } from ".";
|
import type { UseZoomAxis } from ".";
|
||||||
import {
|
import { useZoom, useZoomAxis, zoomDiscreteToRaw, zoomRawToDiscrete } from ".";
|
||||||
useZoom,
|
|
||||||
useZoomAxis,
|
|
||||||
useZoomAxisManager,
|
|
||||||
zoomDiscreteToRaw,
|
|
||||||
zoomRawToDiscrete,
|
|
||||||
} from ".";
|
|
||||||
|
|
||||||
describe("zoom conversion", () => {
|
describe("zoom conversion", () => {
|
||||||
test("zoomRawToDiscrete", () => {
|
test("zoomRawToDiscrete", () => {
|
||||||
|
|
@ -92,18 +86,15 @@ describe("useZoom", () => {
|
||||||
describe("useZoomAxis", () => {
|
describe("useZoomAxis", () => {
|
||||||
test("baseline", () => {
|
test("baseline", () => {
|
||||||
expect(useZoomAxis).toBeDefined();
|
expect(useZoomAxis).toBeDefined();
|
||||||
const axis: UseZoomAxis = useZoomAxis({ raw: 1 });
|
const zoom: UseZoomAxis = useZoomAxis({ raw: 1 });
|
||||||
expect(axis.zoom.discrete.value).toBeDefined();
|
expect(zoom.zoom.discrete.value).toBeDefined();
|
||||||
expect(axis.min.discrete.value).toBeDefined();
|
expect(zoom.min.discrete.value).toBeDefined();
|
||||||
expect(axis.max.discrete.value).toBeDefined();
|
expect(zoom.max.discrete.value).toBeDefined();
|
||||||
expect(axis.default.discrete.value).toBeDefined();
|
expect(zoom.default.discrete.value).toBeDefined();
|
||||||
expect(axis.stepSmall.discrete.value).toBeDefined();
|
expect(zoom.stepSmall.discrete.value).toBeDefined();
|
||||||
expect(axis.isAtMin.value).toBeDefined();
|
zoom.reset();
|
||||||
expect(axis.isAtMax.value).toBeDefined();
|
zoom.zoomIn();
|
||||||
expect(axis.isAtDefault.value).toBeDefined();
|
zoom.zoomOut();
|
||||||
axis.reset();
|
|
||||||
axis.zoomIn();
|
|
||||||
axis.zoomOut();
|
|
||||||
|
|
||||||
useZoomAxis({
|
useZoomAxis({
|
||||||
raw: 1,
|
raw: 1,
|
||||||
|
|
@ -116,192 +107,105 @@ describe("useZoomAxis", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("readonly properties are readonly at compile time", () => {
|
test("readonly properties are readonly at compile time", () => {
|
||||||
const axis = useZoomAxis({ raw: 1 });
|
const zoom = useZoomAxis({ raw: 1 });
|
||||||
|
|
||||||
// These lines assert, at compile time, that the properties are readonly.
|
// These lines assert, at compile time, that the properties are readonly.
|
||||||
// If any of these assignments do NOT produce a TS error, the TypeScript
|
// If any of these assignments do NOT produce a TS error, the TypeScript
|
||||||
// compiler will fail due to the @ts-expect-error directive.
|
// compiler will fail due to the @ts-expect-error directive.
|
||||||
|
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||||
axis.min.raw.value = 32;
|
zoom.min.raw.value = 32;
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||||
axis.min.discrete.value = 32;
|
zoom.min.discrete.value = 32;
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||||
axis.default.raw.value = 2;
|
zoom.default.raw.value = 2;
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
||||||
axis.stepSmall.raw.value = 2;
|
zoom.stepSmall.raw.value = 2;
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
|
||||||
axis.isAtMin.value = true;
|
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
|
||||||
axis.isAtMax.value = true;
|
|
||||||
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
|
|
||||||
axis.isAtDefault.value = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("axis is at min/max/default", async () => {
|
|
||||||
const axis = useZoomAxis({
|
|
||||||
raw: zoomDiscreteToRaw(-20),
|
|
||||||
min: -20,
|
|
||||||
max: 200,
|
|
||||||
default: 5,
|
|
||||||
});
|
|
||||||
expect(axis.isAtMin.value).toBe(true);
|
|
||||||
expect(axis.isAtMax.value).toBe(false);
|
|
||||||
expect(axis.isAtDefault.value).toBe(false);
|
|
||||||
|
|
||||||
axis.zoom.discrete.value = 5;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(axis.isAtMin.value).toBe(false);
|
|
||||||
expect(axis.isAtMax.value).toBe(false);
|
|
||||||
expect(axis.isAtDefault.value).toBe(true);
|
|
||||||
|
|
||||||
axis.zoom.discrete.value = 200;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(axis.isAtMin.value).toBe(false);
|
|
||||||
expect(axis.isAtMax.value).toBe(true);
|
|
||||||
expect(axis.isAtDefault.value).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reset sets zoom to default", async () => {
|
test("reset sets zoom to default", async () => {
|
||||||
const axis = useZoomAxis({
|
const z = useZoomAxis({
|
||||||
raw: 1,
|
raw: 1,
|
||||||
default: 5,
|
default: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// change zoom and ensure reset restores default
|
// change zoom and ensure reset restores default
|
||||||
axis.zoom.discrete.value = 0;
|
z.zoom.discrete.value = 0;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(0);
|
expect(z.zoom.discrete.value).toBe(0);
|
||||||
|
|
||||||
axis.reset();
|
z.reset();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(5);
|
expect(z.zoom.discrete.value).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zoom.raw is writable and reflects source ref", async () => {
|
test("zoom.raw is writable and reflects source ref", async () => {
|
||||||
const rawRef = shallowRef(1);
|
const rawRef = shallowRef(1);
|
||||||
const axis = useZoomAxis({ raw: rawRef });
|
const z = useZoomAxis({ raw: rawRef });
|
||||||
|
|
||||||
expect(axis.zoom.raw.value).toBe(1);
|
expect(z.zoom.raw.value).toBe(1);
|
||||||
|
|
||||||
axis.zoom.raw.value = 3;
|
z.zoom.raw.value = 3;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(axis.zoom.raw.value).toBe(3);
|
expect(z.zoom.raw.value).toBe(3);
|
||||||
expect(rawRef.value).toBe(3);
|
expect(rawRef.value).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zoomIn / zoomOut are callable (no runtime throw)", () => {
|
test("zoomIn / zoomOut are callable (no runtime throw)", () => {
|
||||||
const axis = useZoomAxis({ raw: 1 });
|
const z = useZoomAxis({ raw: 1 });
|
||||||
expect(() => {
|
expect(() => {
|
||||||
axis.zoomIn();
|
z.zoomIn();
|
||||||
axis.zoomOut();
|
z.zoomOut();
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zoomIn snaps up to next big step when between steps", async () => {
|
test("zoomIn snaps up to next big step when between steps", async () => {
|
||||||
const axis = useZoomAxis({ raw: 1, stepBig: 10 });
|
const z = useZoomAxis({ raw: 1, stepBig: 10 });
|
||||||
// set to a value between 10 and 20
|
// set to a value between 10 and 20
|
||||||
axis.zoom.discrete.value = 15;
|
z.zoom.discrete.value = 15;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
axis.zoomIn();
|
z.zoomIn();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(20);
|
expect(z.zoom.discrete.value).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zoomOut snaps down to previous big step when between steps", async () => {
|
test("zoomOut snaps down to previous big step when between steps", async () => {
|
||||||
const axis = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
|
const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
|
||||||
axis.zoomOut();
|
z.zoomOut();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(10);
|
expect(z.zoom.discrete.value).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zoomIn snaps down to previous big step when between steps", async () => {
|
test("zoomIn snaps down to previous big step when between steps", async () => {
|
||||||
const axis = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
|
const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
|
||||||
axis.zoomIn();
|
z.zoomIn();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(20);
|
expect(z.zoom.discrete.value).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aligned steps add/subtract a whole big step", async () => {
|
test("aligned steps add/subtract a whole big step", async () => {
|
||||||
const axis = useZoomAxis({ raw: zoomDiscreteToRaw(20), stepBig: 10 });
|
const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), stepBig: 10 });
|
||||||
|
|
||||||
axis.zoomIn();
|
z.zoomIn();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(30);
|
expect(z.zoom.discrete.value).toBe(30);
|
||||||
|
|
||||||
axis.zoomOut();
|
z.zoomOut();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(20);
|
expect(z.zoom.discrete.value).toBe(20);
|
||||||
|
|
||||||
axis.zoomOut();
|
z.zoomOut();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(axis.zoom.discrete.value).toBe(10);
|
expect(z.zoom.discrete.value).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zoomIn clamps to max when stepping beyond max", async () => {
|
test("zoomIn clamps to max when stepping beyond max", async () => {
|
||||||
const axis = useZoomAxis({
|
const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), max: 25, stepBig: 10 });
|
||||||
raw: zoomDiscreteToRaw(20),
|
|
||||||
max: 25,
|
|
||||||
stepBig: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
axis.zoomIn();
|
z.zoomIn();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
// should clamp to max (25) instead of exceeding it
|
// should clamp to max (25) instead of exceeding it
|
||||||
expect(axis.zoom.discrete.value).toBe(25);
|
expect(z.zoom.discrete.value).toBe(25);
|
||||||
expect(axis.isAtMax.value).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useZoomAxisManager", () => {
|
|
||||||
test("baseline", () => {
|
|
||||||
expect(useZoomAxisManager).toBeDefined();
|
|
||||||
const viewportScrollOffset = shallowRef(0);
|
|
||||||
const viewportSize = shallowRef(1000);
|
|
||||||
const { axis, contentSize, contentSizeIncludingEmptySpace } =
|
|
||||||
useZoomAxisManager({
|
|
||||||
contentSizeForZoom: (zoom) => zoom * 2000,
|
|
||||||
viewportScrollOffset,
|
|
||||||
viewportSize,
|
|
||||||
zoomOptions: {
|
|
||||||
raw: zoomDiscreteToRaw(0),
|
|
||||||
min: 0,
|
|
||||||
max: 10,
|
|
||||||
default: 5,
|
|
||||||
stepSmall: 1,
|
|
||||||
stepBig: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(axis.zoom.discrete.value).toBeDefined();
|
|
||||||
expect(axis.reset).toBeDefined();
|
|
||||||
expect(contentSize.value).toBeDefined();
|
|
||||||
expect(contentSizeIncludingEmptySpace.value).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("min/max/default", async () => {
|
|
||||||
const viewportScrollOffset = shallowRef(0);
|
|
||||||
const viewportSize = shallowRef(1000);
|
|
||||||
const { axis, contentSize, contentSizeIncludingEmptySpace } =
|
|
||||||
useZoomAxisManager({
|
|
||||||
contentSizeForZoom: (zoom) => zoom * 2000,
|
|
||||||
viewportScrollOffset,
|
|
||||||
viewportSize,
|
|
||||||
zoomOptions: {
|
|
||||||
raw: zoomDiscreteToRaw(0),
|
|
||||||
min: -20,
|
|
||||||
max: 200,
|
|
||||||
default: 0,
|
|
||||||
stepSmall: 1,
|
|
||||||
stepBig: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(axis.isAtDefault.value).toBe(true);
|
|
||||||
expect(axis.min.discrete.value).toBe(-20);
|
|
||||||
expect(axis.max.discrete.value).toBe(200);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,100 @@
|
||||||
import type { Px, ZoomDiscrete, ZoomRaw } from "@/lib/units";
|
import type { Px, ZoomDiscrete, ZoomRaw } from "@/lib/units";
|
||||||
import { clamp } from "@vueuse/core";
|
import { clamp } from "@vueuse/core";
|
||||||
import type { ComputedRef, DeepReadonly, MaybeRef, Ref } from "vue";
|
import {
|
||||||
import { computed, toRef, toValue } from "vue";
|
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_INVERSE = 40;
|
||||||
const SCALE_BELOW_THESHOLD = 16;
|
const SCALE_BELOW_THESHOLD = 16;
|
||||||
|
|
@ -62,7 +155,8 @@ const DEFAULT_ZOOM_STEP_BIG_DISCRETE: ZoomDiscrete = 10;
|
||||||
const DEFAULT_ZOOM_STEP_SMALL_DISCRETE: ZoomDiscrete = 1;
|
const DEFAULT_ZOOM_STEP_SMALL_DISCRETE: ZoomDiscrete = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a raw ref in a read-write linked pair of raw+discrete refs with bounds checking in setters.
|
* zoom raw: suitable for scaling, range 1 .. 7.25
|
||||||
|
* zoom discrete: suitable for sliders, range 0 .. 100 or -20 .. 100
|
||||||
*/
|
*/
|
||||||
export function useZoom(
|
export function useZoom(
|
||||||
options: UseZoomOptions,
|
options: UseZoomOptions,
|
||||||
|
|
@ -104,35 +198,13 @@ export function useZoom(
|
||||||
|
|
||||||
export interface UseZoomAxisOptions {
|
export interface UseZoomAxisOptions {
|
||||||
raw: MaybeRef<ZoomRaw>;
|
raw: MaybeRef<ZoomRaw>;
|
||||||
/**
|
// limits
|
||||||
* Limit of zooming out (everything becomes small).
|
|
||||||
*
|
|
||||||
* Defaults to `DEFAULT_ZOOM_MIN_DISCRETE`.
|
|
||||||
*/
|
|
||||||
min?: ZoomDiscrete;
|
min?: ZoomDiscrete;
|
||||||
/**
|
|
||||||
* Limit of zooming in (everything becomes large).
|
|
||||||
*
|
|
||||||
* Defaults to `DEFAULT_ZOOM_MAX_DISCRETE`.
|
|
||||||
*/
|
|
||||||
max?: ZoomDiscrete;
|
max?: ZoomDiscrete;
|
||||||
/**
|
|
||||||
* Default zoom to reset to, e.g. when double clicking associated zoom slider.
|
|
||||||
*
|
|
||||||
* Defaults to `DEFAULT_ZOOM_DISCRETE`.
|
|
||||||
*/
|
|
||||||
default?: ZoomDiscrete;
|
default?: ZoomDiscrete;
|
||||||
/**
|
// Can be used for granular controls like a range slider
|
||||||
* Step size that can be used for granular controls like a range slider.
|
|
||||||
*
|
|
||||||
* Defaults to `DEFAULT_ZOOM_STEP_SMALL_DISCRETE`.
|
|
||||||
*/
|
|
||||||
stepSmall?: ZoomDiscrete;
|
stepSmall?: ZoomDiscrete;
|
||||||
/**
|
// Can be used for buttons
|
||||||
* Step sizee that can be used for buttons.
|
|
||||||
*
|
|
||||||
* Defaults to `DEFAULT_ZOOM_STEP_BIG_DISCRETE`.
|
|
||||||
*/
|
|
||||||
stepBig?: ZoomDiscrete;
|
stepBig?: ZoomDiscrete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,29 +217,10 @@ export interface UseZoomAxis {
|
||||||
default: DeepReadonly<UseZoom>;
|
default: DeepReadonly<UseZoom>;
|
||||||
stepSmall: DeepReadonly<UseZoom>;
|
stepSmall: DeepReadonly<UseZoom>;
|
||||||
|
|
||||||
isAtMin: Readonly<Ref<boolean>>;
|
// Can be triggered by double clicking on the control
|
||||||
isAtMax: Readonly<Ref<boolean>>;
|
|
||||||
isAtDefault: Readonly<Ref<boolean>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can be triggered by double clicking on an associated control like a range slider.
|
|
||||||
*
|
|
||||||
* Applies `default` value.
|
|
||||||
*/
|
|
||||||
reset: () => void;
|
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.
|
||||||
* Increase zoom by `stepBig` amount.
|
|
||||||
*
|
|
||||||
* 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;
|
zoomIn: () => void;
|
||||||
/**
|
|
||||||
* Decrease zoom by `stepBig` amount.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
zoomOut: () => void;
|
zoomOut: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,14 +234,11 @@ export function useZoomAxis(options: UseZoomAxisOptions): UseZoomAxis {
|
||||||
stepBig: stepBigDiscrete = DEFAULT_ZOOM_STEP_BIG_DISCRETE,
|
stepBig: stepBigDiscrete = DEFAULT_ZOOM_STEP_BIG_DISCRETE,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const useZoomMinMax = (raw: MaybeRef<number>): UseZoom => {
|
const zoom = useZoom({ raw, min: minDiscrete, max: maxDiscrete });
|
||||||
return useZoom({ raw, min: minDiscrete, max: maxDiscrete });
|
const min = useZoom({ raw: zoomDiscreteToRaw(minDiscrete) });
|
||||||
};
|
const max = useZoom({ raw: zoomDiscreteToRaw(maxDiscrete) });
|
||||||
const zoom = useZoomMinMax(raw);
|
const default_ = useZoom({ raw: zoomDiscreteToRaw(defaultDiscrete) });
|
||||||
const min = useZoomMinMax(zoomDiscreteToRaw(minDiscrete));
|
const stepSmall = useZoom({ raw: zoomDiscreteToRaw(stepSmallDiscrete) });
|
||||||
const max = useZoomMinMax(zoomDiscreteToRaw(maxDiscrete));
|
|
||||||
const default_ = useZoomMinMax(zoomDiscreteToRaw(defaultDiscrete));
|
|
||||||
const stepSmall = useZoomMinMax(zoomDiscreteToRaw(stepSmallDiscrete));
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
zoom.discrete.value = defaultDiscrete;
|
zoom.discrete.value = defaultDiscrete;
|
||||||
|
|
@ -218,100 +268,65 @@ export function useZoomAxis(options: UseZoomAxisOptions): UseZoomAxis {
|
||||||
default: default_,
|
default: default_,
|
||||||
stepSmall,
|
stepSmall,
|
||||||
|
|
||||||
isAtMin: computed(() => zoom.discrete.value <= minDiscrete),
|
|
||||||
isAtMax: computed(() => zoom.discrete.value >= maxDiscrete),
|
|
||||||
isAtDefault: computed(() => zoom.discrete.value === defaultDiscrete),
|
|
||||||
|
|
||||||
reset,
|
reset,
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseZoomAxisManagerOptions {
|
// export function useZoomAxisManager(
|
||||||
contentSizeForZoom: (zoom: ZoomRaw) => Px;
|
// {
|
||||||
viewportScrollOffset: Ref<Px>;
|
// contentSizeForZoom,
|
||||||
viewportSize: Readonly<Ref<Px>>;
|
// viewportScrollOffset,
|
||||||
zoomOptions: UseZoomAxisOptions;
|
// viewportSize,
|
||||||
}
|
// zoom,
|
||||||
|
// zoomMin,
|
||||||
|
// zoomMax,
|
||||||
|
// }: UseZoomAxisManagerOptions,
|
||||||
|
// ): UseZoomAxisManagerReturn {
|
||||||
|
// const contentSize = computed<Px>(() => contentSizeForZoom());
|
||||||
|
|
||||||
export interface UseZoomAxisManagerReturn {
|
// function contentSizeIncludingEmptySpaceForZoom(zoom?: number): number {
|
||||||
contentSize: ComputedRef<Px>;
|
// return Math.max(contentSizeForZoom(zoom), viewportSize.value);
|
||||||
// contentSizeIncludingEmptySpaceForZoom: (zoom?: ZoomRaw) => Px;
|
// }
|
||||||
contentSizeIncludingEmptySpace: ComputedRef<Px>;
|
// const contentSizeIncludingEmptySpace = computed<number>(() =>
|
||||||
axis: UseZoomAxis;
|
// contentSizeIncludingEmptySpaceForZoom()
|
||||||
}
|
// );
|
||||||
|
|
||||||
/**
|
// // When zooming, timeline should stay centered at current viewport center
|
||||||
* Harness a bunch of functionality related to zooming along a single axis.
|
// const zoomWrapper = computed<number>({
|
||||||
*/
|
// get() {
|
||||||
export function useZoomAxisManager(
|
// return zoom.value;
|
||||||
options: UseZoomAxisManagerOptions,
|
// },
|
||||||
): UseZoomAxisManagerReturn {
|
// set(value) {
|
||||||
const {
|
// // sanitize
|
||||||
contentSizeForZoom,
|
// value = clamp(value, zoomMin, zoomMax);
|
||||||
viewportScrollOffset,
|
// // calculate current and anticipated content size
|
||||||
viewportSize,
|
// const currentContentSize = contentSizeIncludingEmptySpaceForZoom();
|
||||||
zoomOptions,
|
// const nextContentSize = contentSizeIncludingEmptySpaceForZoom(
|
||||||
} = options;
|
// value,
|
||||||
|
// );
|
||||||
|
// // calculate current offset of center
|
||||||
|
// const halfViewportSize = viewportSize.value / 2;
|
||||||
|
// const currentOffsetOfCenter = viewportScrollOffset.value +
|
||||||
|
// halfViewportSize;
|
||||||
|
|
||||||
const contentSize = computed<Px>(() =>
|
// // keep the timeline centered around current viewport's center
|
||||||
contentSizeForZoom(toValue(zoomOptions.raw))
|
// const percent = currentOffsetOfCenter / currentContentSize;
|
||||||
);
|
// const nextOffsetOfCenter = percent * nextContentSize;
|
||||||
|
// let nextOffset = nextOffsetOfCenter - halfViewportSize;
|
||||||
|
// const maxOffset = nextContentSize - viewportSize.value;
|
||||||
|
// nextOffset = clamp(nextOffset, 0, maxOffset);
|
||||||
|
|
||||||
const contentSizeIncludingEmptySpaceForZoom = (zoom: number): number =>
|
// zoom.value = value;
|
||||||
Math.max(contentSizeForZoom(zoom), viewportSize.value);
|
// viewportScrollOffset.value = nextOffset;
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
const contentSizeIncludingEmptySpace = computed<number>(() =>
|
// return {
|
||||||
contentSizeIncludingEmptySpaceForZoom(toValue(zoomOptions.raw))
|
// contentSize,
|
||||||
);
|
// contentSizeIncludingEmptySpaceForZoom,
|
||||||
|
// contentSizeIncludingEmptySpace,
|
||||||
const useZoomWithAutomaticViewportOffset = (
|
// zoom: zoomWrapper,
|
||||||
sourceZoom: MaybeRef<ZoomRaw>,
|
// };
|
||||||
) => {
|
// }
|
||||||
const zoom = toRef(sourceZoom);
|
|
||||||
|
|
||||||
// When zooming, timeline should stay centered at current viewport center
|
|
||||||
return computed<ZoomRaw>({
|
|
||||||
get() {
|
|
||||||
return zoom.value;
|
|
||||||
},
|
|
||||||
set(value: ZoomRaw) {
|
|
||||||
// Note: no need to clamp/sanitize, since useZoomAxis already takes care of that.
|
|
||||||
|
|
||||||
// calculate current and anticipated content size
|
|
||||||
const currentContentSize = contentSizeIncludingEmptySpaceForZoom(
|
|
||||||
zoom.value,
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
const maxOffset = nextContentSize - viewportSize.value;
|
|
||||||
const nextOffset = nextOffsetOfCenter - halfViewportSize;
|
|
||||||
const nextOffsetClamped = clamp(nextOffset, 0, maxOffset);
|
|
||||||
|
|
||||||
zoom.value = value;
|
|
||||||
viewportScrollOffset.value = nextOffsetClamped;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const axis = useZoomAxis({
|
|
||||||
...zoomOptions,
|
|
||||||
raw: useZoomWithAutomaticViewportOffset(zoomOptions.raw),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentSize,
|
|
||||||
// contentSizeIncludingEmptySpaceForZoom,
|
|
||||||
contentSizeIncludingEmptySpace,
|
|
||||||
axis,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { tryOnScopeDispose } from "@vueuse/core";
|
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
type MaybeRefOrGetter,
|
type MaybeRefOrGetter,
|
||||||
type Ref,
|
type Ref,
|
||||||
shallowRef,
|
|
||||||
toValue,
|
toValue,
|
||||||
watch,
|
watch,
|
||||||
type WatchHandle,
|
type WatchHandle,
|
||||||
|
|
@ -20,13 +18,11 @@ export function toPx(value: MaybeRefOrGetter<number>) {
|
||||||
function multiWatchHandle(...handles: WatchHandle[]): WatchHandle {
|
function multiWatchHandle(...handles: WatchHandle[]): WatchHandle {
|
||||||
const watchHandle = () => {
|
const watchHandle = () => {
|
||||||
handles.forEach((h) => h.stop());
|
handles.forEach((h) => h.stop());
|
||||||
};
|
}
|
||||||
watchHandle.pause = () => handles.forEach((h) => h.pause());
|
watchHandle.pause = () => handles.forEach((h) => h.pause());
|
||||||
watchHandle.resume = () => handles.forEach((h) => h.resume());
|
watchHandle.resume = () => handles.forEach((h) => h.resume());
|
||||||
watchHandle.stop = watchHandle;
|
watchHandle.stop = watchHandle;
|
||||||
|
|
||||||
tryOnScopeDispose(watchHandle);
|
|
||||||
|
|
||||||
return watchHandle;
|
return watchHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,37 +45,3 @@ export function bindTwoWay<T>(ref1: Ref<T>, ref2: Ref<T>): WatchHandle {
|
||||||
|
|
||||||
return multiWatchHandle(handle1, handle2);
|
return multiWatchHandle(handle1, handle2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure value is always set through the given setter function.
|
|
||||||
*/
|
|
||||||
export function useSetterRef<T>(
|
|
||||||
initialValue: T,
|
|
||||||
setter: (value: T) => T,
|
|
||||||
): Ref<T> {
|
|
||||||
const ref = shallowRef<T>(setter(initialValue));
|
|
||||||
return computed<T>({
|
|
||||||
get() {
|
|
||||||
return ref.value;
|
|
||||||
},
|
|
||||||
set(newValue: T) {
|
|
||||||
ref.value = setter(newValue);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a readable & writable ref, because v-model can't handle nested refs.
|
|
||||||
*
|
|
||||||
* See also: https://github.com/vuejs/core/issues/14174
|
|
||||||
*/
|
|
||||||
export function useProxyRef<T>(get: () => Ref<T>): Ref<T> {
|
|
||||||
return computed<T>({
|
|
||||||
get() {
|
|
||||||
return get().value;
|
|
||||||
},
|
|
||||||
set(value: T) {
|
|
||||||
get().value = value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ErrorScreen from '@/components/ErrorScreen.vue';
|
|
||||||
import LoadingScreen from '@/components/LoadingScreen.vue';
|
import LoadingScreen from '@/components/LoadingScreen.vue';
|
||||||
import PreviewScnene from '@/components/editor/PreviewScnene.vue';
|
import PreviewScnene from '@/components/editor/PreviewScnene.vue';
|
||||||
import InspectorPanel from '@/components/inspector/InspectorPanel.vue';
|
import TrackInfo from '@/components/editor/TrackInfo.vue';
|
||||||
import TimelinePanel from '@/components/timeline/TimelinePanel.vue';
|
import TimelinePanel from '@/components/timeline/TimelinePanel.vue';
|
||||||
import onInputKeyStroke from '@/lib/onInputKeyStroke';
|
|
||||||
import type { UseZoomAxis } from '@/lib/useZoomAxis';
|
|
||||||
import { useScrollStore } from '@/store/ScrollStore';
|
import { useScrollStore } from '@/store/ScrollStore';
|
||||||
import { useTimelineStore } from '@/store/TimelineStore';
|
import { useTimelineStore } from '@/store/TimelineStore';
|
||||||
import { useTrackStore } from '@/store/TrackStore';
|
import { useTrackStore } from '@/store/TrackStore';
|
||||||
import { useEventListener } from '@vueuse/core';
|
import { useEventListener, useRafFn } from '@vueuse/core';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, useTemplateRef, watch } from 'vue';
|
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute } from 'vue-router';
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute } from 'vue-router';
|
||||||
|
import ErrorScreen from '@/components/ErrorScreen.vue';
|
||||||
|
|
||||||
const scrollContainer = useTemplateRef('scrollContainer');
|
const scrollContainer = useTemplateRef('scrollContainer');
|
||||||
const scrollStore = useScrollStore();
|
const scrollStore = useScrollStore();
|
||||||
|
|
@ -27,42 +25,45 @@ watch(() => String(route.params.trackName), fetchTrack, { immediate: true })
|
||||||
async function fetchTrack(trackName: string) {
|
async function fetchTrack(trackName: string) {
|
||||||
await trackStore.fill();
|
await trackStore.fill();
|
||||||
await trackStore.setCurrentAudioTrackByName(trackName);
|
await trackStore.setCurrentAudioTrackByName(trackName);
|
||||||
const audioTrack = trackStore.currentAudioTrack;
|
timeline.setAudioTrack(trackStore.currentAudioTrack);
|
||||||
timeline.setAudioTrack(audioTrack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { currentAudioTrack, currentAudioTrackName, audioTrackStatus, audioTrackProgress, audioTrackError } = storeToRefs(trackStore);
|
const { currentAudioTrack, currentAudioTrackName, audioTrackStatus, audioTrackProgress, audioTrackError } = storeToRefs(trackStore);
|
||||||
|
|
||||||
onInputKeyStroke((event) => (event.key === 'k'), (event) => {
|
const fpsLimit = 60
|
||||||
const player = timeline.player;
|
const { pause, resume } = useRafFn(({ delta }) => {
|
||||||
player?.stop({ rememberPosition: true });
|
if (currentAudioTrack.value && trackStore.isPlaying) {
|
||||||
event.preventDefault();
|
const deltaSeconds = delta / 1000;
|
||||||
});
|
timeline.advance(deltaSeconds);
|
||||||
|
}
|
||||||
onInputKeyStroke((event) => event.key === ' ', (event) => {
|
}, { immediate: false, fpsLimit });
|
||||||
if (!event.repeat) {
|
|
||||||
const player = timeline.player;
|
watch(() => trackStore.isPlaying, (isPlaying: boolean) => {
|
||||||
if (player) {
|
if (isPlaying) {
|
||||||
const rememberPosition = event.shiftKey;
|
resume();
|
||||||
if (player.playback.isPlaying.value) {
|
} else {
|
||||||
player.stop({ rememberPosition });
|
pause();
|
||||||
} else {
|
}
|
||||||
player.play();
|
}, { immediate: true });
|
||||||
}
|
|
||||||
}
|
useEventListener(document, 'keydown', (event) => {
|
||||||
|
if (event.key === ' ') {
|
||||||
|
if (!event.repeat) {
|
||||||
|
trackStore.togglePlayPause();
|
||||||
|
}
|
||||||
|
// Prevent auto-repeated spacebar from triggering buttons
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
// Prevent auto-repeated spacebar from triggering buttons
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeRouteLeave((_to, _from, next) => {
|
onBeforeRouteLeave((_to, _from, next) => {
|
||||||
timeline.player?.stop();
|
trackStore.stop();
|
||||||
trackStore.setCurrentAudioTrackByName("");
|
trackStore.setCurrentAudioTrackByName("");
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
onBeforeRouteUpdate((to, from, next) => {
|
onBeforeRouteUpdate((to, from, next) => {
|
||||||
if (to.params.trackName !== from.params.trackName) {
|
if (to.params.trackName !== from.params.trackName) {
|
||||||
timeline.player?.stop();
|
trackStore.pause();
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
@ -73,10 +74,6 @@ const errorTitle = computed(() => audioTrackStatus.value === 'error'
|
||||||
: "Error loading an unknown track"
|
: "Error loading an unknown track"
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
// nested composable marked with markRaw
|
|
||||||
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis
|
|
||||||
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -87,45 +84,33 @@ const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
|
||||||
<ErrorScreen v-if="audioTrackStatus === 'error'" :title="errorTitle" :description="audioTrackError" />
|
<ErrorScreen v-if="audioTrackStatus === 'error'" :title="errorTitle" :description="audioTrackError" />
|
||||||
|
|
||||||
<div v-if="audioTrackStatus === 'ready'" class="tw:h-full tw:isolate">
|
<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">
|
<div class="tw:h-full tw:max-h-full tw:flex tw:flex-col tw:gap-2">
|
||||||
|
|
||||||
<div class="tw:flex-1 tw:flex tw:flex-row tw:min-h-0">
|
<div class="tw:flex-1">
|
||||||
<div class="tw:flex-1 tw:bg-(--player-background-color)">
|
<TrackInfo v-if="false && currentAudioTrack" :track="currentAudioTrack" :edit="false" />
|
||||||
|
|
||||||
<!-- <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">
|
||||||
<!-- TODO: debug data -->
|
<p>Viewport size:
|
||||||
<div class="tw:flex tw:flex-col tw:items-center tw:p-8 tw:text-sm">
|
{{ timeline.viewportWidth.toFixed(2) }} x {{ timeline.viewportHeight.toFixed(2) }}
|
||||||
<h1 class="tw:text-2xl tw:pb-4">🚧 Section Under Construction! 🚧</h1>
|
</p>
|
||||||
<p>Viewport size:
|
<p>Scroll Offset:
|
||||||
{{ timeline.viewportWidth.toFixed(2) }} x {{ timeline.viewportHeight.toFixed(2) }}
|
{{ timeline.viewportScrollOffsetLeft.toFixed(2) }} x {{ timeline.viewportScrollOffsetTop.toFixed(2) }}
|
||||||
</p>
|
</p>
|
||||||
<p>Scroll Offset:
|
<p>Duration: {{ timeline.duration }}</p>
|
||||||
{{ timeline.viewportScrollOffsetLeft.toFixed(2) }} x {{ timeline.viewportScrollOffsetTop.toFixed(2) }}
|
<p>Viewport duration: {{ timeline.viewportDurationSeconds.toFixed(3) }}</p>
|
||||||
</p>
|
<hr/>
|
||||||
<p>Duration: {{ timeline.duration }}</p>
|
<p>Zoom: {{ timeline.viewportZoomHorizontal.toFixed(4) }} x {{ timeline.viewportZoomVertical.toFixed(4) }}</p>
|
||||||
<p>Viewport duration: {{ timeline.viewportDurationSeconds.toFixed(3) }}</p>
|
<p>Content size: {{ timeline.contentWidth }} x ({{ timeline.contentHeight }} = {{ timeline.trackHeight }}px x {{ timeline.visibleTracks.length }} tracks)</p>
|
||||||
<hr />
|
<p>including empty space: {{ timeline.contentWidthIncludingEmptySpace.toFixed(2) }}</p>
|
||||||
<p>Zoom: {{ viewportZoomHorizontal.zoom.raw.value.toFixed(4) }} x {{
|
<hr/>
|
||||||
viewportZoomVertical.zoom.raw.value.toFixed(4) }}
|
<p>Viewport In/Out ...Seconds: {{ timeline.viewportInSeconds.toFixed(3) }} .. {{ timeline.viewportOutSeconds.toFixed(3) }}</p>
|
||||||
</p>
|
<p>... Loop Offset Beats: {{ timeline.viewportInLoopOffsetBeats.toFixed(3) }} .. {{ timeline.viewportOutLoopOffsetBeats.toFixed(3) }}</p>
|
||||||
<p>Content size: {{ timeline.contentWidth }} x ({{ timeline.contentHeight }} = {{ timeline.trackHeight
|
<hr/>
|
||||||
}}px x {{ timeline.visibleTracks.length }} tracks)</p>
|
<p>playheadPosition: {{ timeline.playheadPosition.toFixed(3) }}</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) }} | {{
|
|
||||||
timeline.playheadPositionLoopOffsetBeats.toFixed(3) }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PreviewScnene v-if="false" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InspectorPanel />
|
<PreviewScnene v-if="false" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimelinePanel class="tw:flex-1 tw:min-h-50" />
|
<TimelinePanel class="tw:flex-1 tw:min-h-50" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
import audioEngine, {
|
|
||||||
type PlayerControls,
|
|
||||||
useLivePlaybackPosition,
|
|
||||||
} from "@/audio/AudioEngine";
|
|
||||||
import {
|
import {
|
||||||
type AudioTrack,
|
type AudioTrack,
|
||||||
beatsToSeconds,
|
beatsToSeconds,
|
||||||
|
|
@ -9,6 +5,7 @@ import {
|
||||||
secondsToBeats,
|
secondsToBeats,
|
||||||
totalDurationSeconds,
|
totalDurationSeconds,
|
||||||
} from "@/lib/AudioTrack";
|
} from "@/lib/AudioTrack";
|
||||||
|
import { modRange } from "@/lib/math";
|
||||||
import {
|
import {
|
||||||
emptyTimelineTracksMap,
|
emptyTimelineTracksMap,
|
||||||
generateClips,
|
generateClips,
|
||||||
|
|
@ -18,43 +15,26 @@ import {
|
||||||
timelineTracksArray,
|
timelineTracksArray,
|
||||||
} from "@/lib/Timeline";
|
} from "@/lib/Timeline";
|
||||||
import type { Beats, Px, Seconds } from "@/lib/units";
|
import type { Beats, Px, Seconds } from "@/lib/units";
|
||||||
import type { UseZoomAxis } from "@/lib/useZoomAxis";
|
import { useZoomAxisOld } from "@/lib/useZoomAxis";
|
||||||
import { useZoomAxisManager, zoomRawToDiscrete } from "@/lib/useZoomAxis";
|
|
||||||
import { toPx } from "@/lib/vue";
|
import { toPx } from "@/lib/vue";
|
||||||
import { clamp, useLocalStorage } from "@vueuse/core";
|
import { clamp, useLocalStorage } from "@vueuse/core";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, markRaw, shallowRef, watch } from "vue";
|
import { computed, shallowRef } from "vue";
|
||||||
|
|
||||||
export const DEFAULT_ZOOM_RAW_HORIZONTAL = 1.0;
|
export const DEFAULT_ZOOM_HORIZONTAL = 1.0;
|
||||||
export const DEFAULT_ZOOM_RAW_VERTICAL = 3.0;
|
export const DEFAULT_ZOOM_VERTICAL = 3.0;
|
||||||
|
|
||||||
const DEFAULT_HEADER_HEIGHT = 34; // px
|
const DEFAULT_HEADER_HEIGHT = 34; // px
|
||||||
// TODO: on mobile default to 100px or even less
|
// TODO: on mobile default to 100px or even less
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 140; // px
|
const DEFAULT_SIDEBAR_WIDTH = 140; // px
|
||||||
const DEFAULT_TRACK_HEIGHT = 72 / DEFAULT_ZOOM_RAW_VERTICAL; // px
|
const DEFAULT_TRACK_HEIGHT = 72 / DEFAULT_ZOOM_VERTICAL; // px
|
||||||
|
|
||||||
const EXTRA_DURATION_AT_END_SECONDS = 0;
|
const EXTRA_DURATION_AT_END_SECONDS = 0;
|
||||||
|
|
||||||
export const useTimelineStore = defineStore("timeline", {
|
export const useTimelineStore = defineStore("timeline", {
|
||||||
state: () => {
|
state: () => {
|
||||||
// actual content
|
// actual content
|
||||||
const _audioTrack = shallowRef<AudioTrack | null>(null);
|
const audioTrack = shallowRef<AudioTrack | null>(null);
|
||||||
const player = shallowRef<PlayerControls | null>(null);
|
|
||||||
const audioTrack = computed({
|
|
||||||
get() {
|
|
||||||
return _audioTrack.value;
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
_audioTrack.value = value;
|
|
||||||
player.value?.stop();
|
|
||||||
if (value) {
|
|
||||||
player.value = audioEngine.initPlayer(value);
|
|
||||||
} else {
|
|
||||||
player.value = null;
|
|
||||||
audioEngine.shutdown();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const tracksMap = shallowRef(emptyTimelineTracksMap());
|
const tracksMap = shallowRef(emptyTimelineTracksMap());
|
||||||
const markers = [] as TimelineMarkerData[];
|
const markers = [] as TimelineMarkerData[];
|
||||||
|
|
||||||
|
|
@ -70,16 +50,19 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
return _viewportScrollOffsetLeft.value;
|
return _viewportScrollOffsetLeft.value;
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
const max = contentWidthIncludingEmptySpace.value - viewportWidth.value;
|
_viewportScrollOffsetLeft.value = clamp(
|
||||||
_viewportScrollOffsetLeft.value = clamp(value, 0, max);
|
value,
|
||||||
|
0,
|
||||||
|
contentWidthIncludingEmptySpace.value - viewportWidth.value,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// horizontal zoom 1 equals to full timeline duration
|
// horizontal zoom 1 equals to full timeline duration
|
||||||
const _viewportZoomHorizontal = shallowRef(DEFAULT_ZOOM_RAW_HORIZONTAL);
|
const _viewportZoomHorizontal = shallowRef(DEFAULT_ZOOM_HORIZONTAL);
|
||||||
const _viewportZoomVertical = useLocalStorage(
|
const _viewportZoomVertical = useLocalStorage(
|
||||||
"timeline.viewportZoomVertical",
|
"timeline.viewportZoomVertical",
|
||||||
DEFAULT_ZOOM_RAW_VERTICAL,
|
DEFAULT_ZOOM_VERTICAL,
|
||||||
);
|
);
|
||||||
|
|
||||||
function trackHeightForZoom(zoom: number = _viewportZoomVertical.value) {
|
function trackHeightForZoom(zoom: number = _viewportZoomVertical.value) {
|
||||||
|
|
@ -102,62 +85,33 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
return trackHeight * visibleTracks.value.length;
|
return trackHeight * visibleTracks.value.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: zoom around playhead
|
||||||
const {
|
const {
|
||||||
contentSize: contentWidth,
|
contentSize: contentWidth,
|
||||||
|
// contentSizeIncludingEmptySpaceForZoom: contentWidthIncludingEmptySpaceForZoom,
|
||||||
contentSizeIncludingEmptySpace: contentWidthIncludingEmptySpace,
|
contentSizeIncludingEmptySpace: contentWidthIncludingEmptySpace,
|
||||||
axis: viewportZoomHorizontal,
|
zoom: viewportZoomHorizontal,
|
||||||
} = useZoomAxisManager({
|
} = useZoomAxisOld({
|
||||||
contentSizeForZoom: contentWidthForZoom,
|
contentSizeForZoom: contentWidthForZoom,
|
||||||
viewportScrollOffset: viewportScrollOffsetLeft,
|
viewportScrollOffset: viewportScrollOffsetLeft,
|
||||||
viewportSize: viewportWidth,
|
viewportSize: viewportWidth,
|
||||||
zoomOptions: {
|
zoom: _viewportZoomHorizontal,
|
||||||
raw: _viewportZoomHorizontal,
|
zoomMin: 0.5,
|
||||||
min: -20,
|
zoomMax: 19.75,
|
||||||
max: 200,
|
|
||||||
default: zoomRawToDiscrete(DEFAULT_ZOOM_RAW_HORIZONTAL),
|
|
||||||
stepSmall: 1,
|
|
||||||
stepBig: 10,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
markRaw(viewportZoomHorizontal);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contentSize: contentHeight,
|
contentSize: contentHeight,
|
||||||
|
// contentSizeIncludingEmptySpaceForZoom: contentHeightIncludingEmptySpaceForZoom,
|
||||||
contentSizeIncludingEmptySpace: contentHeightIncludingEmptySpace,
|
contentSizeIncludingEmptySpace: contentHeightIncludingEmptySpace,
|
||||||
axis: viewportZoomVertical,
|
zoom: viewportZoomVertical,
|
||||||
} = useZoomAxisManager({
|
} = useZoomAxisOld({
|
||||||
contentSizeForZoom: contentHeightForZoom,
|
contentSizeForZoom: contentHeightForZoom,
|
||||||
viewportScrollOffset: viewportScrollOffsetTop,
|
viewportScrollOffset: viewportScrollOffsetTop,
|
||||||
viewportSize: viewportHeight,
|
viewportSize: viewportHeight,
|
||||||
zoomOptions: {
|
zoom: _viewportZoomVertical,
|
||||||
raw: _viewportZoomVertical,
|
zoomMin: 1,
|
||||||
min: 0,
|
zoomMax: 7.25,
|
||||||
max: 100,
|
|
||||||
default: zoomRawToDiscrete(DEFAULT_ZOOM_RAW_VERTICAL),
|
|
||||||
stepSmall: 1,
|
|
||||||
stepBig: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
markRaw(viewportZoomVertical);
|
|
||||||
|
|
||||||
const {
|
|
||||||
// Pinia store doesn't have any lifecycle, so it never stops.
|
|
||||||
// stop: stopLivePlaybackPosition,
|
|
||||||
position: livePlaybackPosition,
|
|
||||||
} = useLivePlaybackPosition(
|
|
||||||
() => player.value?.playback ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// This does not work great, but at least it's ~something~.
|
|
||||||
// Due to Pinia design, we can't use actions in setup script,
|
|
||||||
// unless all dependent getters & actions are moved in too.
|
|
||||||
watch(livePlaybackPosition, () => {
|
|
||||||
const timeline = useTimelineStore();
|
|
||||||
timeline.ensurePlayheadWithinViewport();
|
|
||||||
});
|
|
||||||
watch(viewportZoomHorizontal.zoom.raw, () => {
|
|
||||||
const timeline = useTimelineStore();
|
|
||||||
timeline.ensurePlayheadCenteredWithinViewport();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
|
|
@ -190,18 +144,15 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
viewportScrollOffsetTop,
|
viewportScrollOffsetTop,
|
||||||
viewportScrollOffsetLeft,
|
viewportScrollOffsetLeft,
|
||||||
|
|
||||||
/* viewport zoom axes, managed by zoom sliders. */
|
/* viewport zoom, managed by zoom sliders. */
|
||||||
|
|
||||||
// horizontal zoom 1 equals to full timeline duration
|
// horizontal zoom 1 equals to full timeline duration
|
||||||
viewportZoomHorizontal,
|
viewportZoomHorizontal,
|
||||||
viewportZoomVertical,
|
viewportZoomVertical,
|
||||||
|
|
||||||
// playhead and scrubbing / preview positions in absolute seconds
|
// playhead and scrubbing / preview positions in absolute seconds
|
||||||
player,
|
playheadPosition: 0,
|
||||||
playheadPosition: livePlaybackPosition,
|
scrubbingPosition: NaN,
|
||||||
isPlaying: computed<boolean>(() =>
|
|
||||||
player.value?.playback.isPlaying.value ?? false
|
|
||||||
),
|
|
||||||
|
|
||||||
// auxilary elements
|
// auxilary elements
|
||||||
headerHeight: DEFAULT_HEADER_HEIGHT,
|
headerHeight: DEFAULT_HEADER_HEIGHT,
|
||||||
|
|
@ -216,9 +167,8 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
return toPx(this.contentHeightIncludingEmptySpace);
|
return toPx(this.contentHeightIncludingEmptySpace);
|
||||||
},
|
},
|
||||||
durationIncludingEmptySpace(): number {
|
durationIncludingEmptySpace(): number {
|
||||||
const axis = this.viewportZoomHorizontal as any as UseZoomAxis;
|
return this.viewportZoomHorizontal < 1
|
||||||
return axis.zoom.raw.value < 1
|
? this.duration / this.viewportZoomHorizontal
|
||||||
? this.duration / axis.zoom.raw.value
|
|
||||||
: this.duration;
|
: this.duration;
|
||||||
},
|
},
|
||||||
durationBeatsIncludingEmptySpace(): number {
|
durationBeatsIncludingEmptySpace(): number {
|
||||||
|
|
@ -282,14 +232,20 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
}
|
}
|
||||||
return secondsToBeats(this.audioTrack, this.duration);
|
return secondsToBeats(this.audioTrack, this.duration);
|
||||||
},
|
},
|
||||||
playheadPositionLoopOffsetBeats(): Beats {
|
playheadPositionBeats(): Beats {
|
||||||
if (!this.audioTrack) {
|
if (!this.audioTrack) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const loopOffsetSeconds = introWithLoopOffsetDurationSeconds(this.audioTrack);
|
return secondsToBeats(this.audioTrack, this.playheadPosition);
|
||||||
const playheadLoopOffsetSeconds = this.playheadPosition - loopOffsetSeconds;
|
},
|
||||||
const playheadLoopOffsetBeats = secondsToBeats(this.audioTrack, playheadLoopOffsetSeconds);
|
scrubbingPositionBeats(): Beats {
|
||||||
return playheadLoopOffsetBeats;
|
if (!this.audioTrack) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(this.scrubbingPosition)) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
return secondsToBeats(this.audioTrack, this.scrubbingPosition);
|
||||||
},
|
},
|
||||||
/* Measurements and convertions */
|
/* Measurements and convertions */
|
||||||
pixelsToSeconds(_state) {
|
pixelsToSeconds(_state) {
|
||||||
|
|
@ -356,11 +312,13 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
this.audioTrack = null;
|
this.audioTrack = null;
|
||||||
this.duration = 0;
|
this.duration = 0;
|
||||||
this.resetViewport();
|
this.resetViewport();
|
||||||
|
this.playheadPosition = 0;
|
||||||
|
this.scrubbingPosition = NaN;
|
||||||
this.tracksMap = emptyTimelineTracksMap();
|
this.tracksMap = emptyTimelineTracksMap();
|
||||||
this.markers = [];
|
this.markers = [];
|
||||||
},
|
},
|
||||||
resetViewport() {
|
resetViewport() {
|
||||||
this.viewportZoomHorizontal.reset();
|
this.viewportZoomHorizontal = DEFAULT_ZOOM_HORIZONTAL;
|
||||||
// Keep it in local storage.
|
// Keep it in local storage.
|
||||||
// this.viewportZoomVertical = DEFAULT_ZOOM_VERTICAL;
|
// this.viewportZoomVertical = DEFAULT_ZOOM_VERTICAL;
|
||||||
},
|
},
|
||||||
|
|
@ -385,8 +343,7 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
const duration = out_ - in_;
|
const duration = out_ - in_;
|
||||||
const totalDuration = totalDurationSeconds(audioTrack);
|
const totalDuration = totalDurationSeconds(audioTrack);
|
||||||
const zoom = totalDuration / duration;
|
const zoom = totalDuration / duration;
|
||||||
const axis = this.viewportZoomHorizontal as any as UseZoomAxis;
|
this.viewportZoomHorizontal = zoom;
|
||||||
axis.zoom.raw.value = zoom;
|
|
||||||
// let the viewport adjust and propagate size changes.
|
// let the viewport adjust and propagate size changes.
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
const left = this.secondsToPixels(in_);
|
const left = this.secondsToPixels(in_);
|
||||||
|
|
@ -394,9 +351,8 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
zoomToggleBetweenWholeAndLoop() {
|
zoomToggleBetweenWholeAndLoop() {
|
||||||
const axis = this.viewportZoomHorizontal as any as UseZoomAxis;
|
if (this.viewportZoomHorizontal !== 1) {
|
||||||
if (axis.zoom.raw.value !== 1) {
|
this.viewportZoomHorizontal = 1;
|
||||||
axis.zoom.raw.value = 1;
|
|
||||||
} else {
|
} else {
|
||||||
this.zoomToLoop();
|
this.zoomToLoop();
|
||||||
}
|
}
|
||||||
|
|
@ -410,10 +366,25 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
this.duration = totalDurationSeconds(track) +
|
this.duration = totalDurationSeconds(track) +
|
||||||
EXTRA_DURATION_AT_END_SECONDS;
|
EXTRA_DURATION_AT_END_SECONDS;
|
||||||
this.resetViewport();
|
this.resetViewport();
|
||||||
|
this.playheadPosition = 0;
|
||||||
|
this.scrubbingPosition = NaN;
|
||||||
// regenerate tracks content
|
// regenerate tracks content
|
||||||
this.tracksMap = generateClips(track);
|
this.tracksMap = generateClips(track);
|
||||||
this.markers = generateMarkers(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() {
|
ensurePlayheadWithinViewport() {
|
||||||
if (
|
if (
|
||||||
this.playheadPosition < this.viewportInSeconds ||
|
this.playheadPosition < this.viewportInSeconds ||
|
||||||
|
|
@ -429,40 +400,5 @@ export const useTimelineStore = defineStore("timeline", {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ensurePlayheadCenteredWithinViewport() {
|
|
||||||
const target = this.secondsToPixels(this.playheadPosition) -
|
|
||||||
this.viewportWidth / 2;
|
|
||||||
this.viewportScrollOffsetLeft = clamp(
|
|
||||||
target,
|
|
||||||
0,
|
|
||||||
this.contentWidth - this.viewportWidth,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
rewindToIntro() {
|
|
||||||
const p = this.player;
|
|
||||||
if (!p) return;
|
|
||||||
p.seek(0, { scrub: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
rewindToWindUp() {
|
|
||||||
const { audioTrack, player: p } = this;
|
|
||||||
if (!audioTrack || !p) return;
|
|
||||||
|
|
||||||
const preWindUpGap: Seconds = 3;
|
|
||||||
// toggle between exact wind-up moment and a short build-up before that.
|
|
||||||
const current = p.playback.getCurrentPosition();
|
|
||||||
const exactWindUp = audioTrack.WindUpTimer;
|
|
||||||
const beforeWindUp = exactWindUp - preWindUpGap;
|
|
||||||
const target = current !== beforeWindUp ? beforeWindUp : exactWindUp;
|
|
||||||
p.seek(target, { scrub: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
rewindToLoop() {
|
|
||||||
const { audioTrack, player: p } = this;
|
|
||||||
if (!audioTrack || !p) return;
|
|
||||||
const target = introWithLoopOffsetDurationSeconds(audioTrack);
|
|
||||||
p.seek(target, { scrub: false });
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import audioEngine, { VOLUME_MAX } from "@/audio/AudioEngine";
|
import audioEngine, { VOLUME_MAX } from "@/audio/AudioEngine";
|
||||||
import type { AudioTrack, Codenames, Language } from "@/lib/AudioTrack";
|
import { introWithLoopOffsetDurationSeconds, totalDurationSeconds, type AudioTrack, type Codenames, type Language } from "@/lib/AudioTrack";
|
||||||
import { sleep } from "@/lib/sleep";
|
|
||||||
import { useStorage } from "@vueuse/core";
|
import { useStorage } from "@vueuse/core";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { shallowRef } from "vue";
|
import { shallowRef } from "vue";
|
||||||
import codenamesJsonUrl from "/MuzikaGromcheCodenames.json?url";
|
import codenamesJsonUrl from "/MuzikaGromcheCodenames.json?url";
|
||||||
import tracksJsonUrl from "/MuzikaGromcheTracks.json?url";
|
import tracksJsonUrl from "/MuzikaGromcheTracks.json?url";
|
||||||
|
import { sleep } from "@/lib/sleep";
|
||||||
|
|
||||||
// Don't mark it as unused, it is needed for debugging
|
// Don't mark it as unused, it is needed for debugging
|
||||||
sleep(0);
|
sleep(0);
|
||||||
|
|
@ -38,6 +38,12 @@ export const useTrackStore = defineStore("track", {
|
||||||
muted: useStorage("player-volume-muted", false),
|
muted: useStorage("player-volume-muted", false),
|
||||||
// persisted volume 0..1
|
// persisted volume 0..1
|
||||||
volume: useStorage("player-volume", 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: {
|
getters: {
|
||||||
|
|
@ -61,6 +67,17 @@ export const useTrackStore = defineStore("track", {
|
||||||
null;
|
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: {
|
actions: {
|
||||||
async fill(signal?: AbortSignal) {
|
async fill(signal?: AbortSignal) {
|
||||||
|
|
@ -125,6 +142,8 @@ export const useTrackStore = defineStore("track", {
|
||||||
},
|
},
|
||||||
|
|
||||||
async setCurrentAudioTrackByName(trackName: string, signal?: AbortSignal) {
|
async setCurrentAudioTrackByName(trackName: string, signal?: AbortSignal) {
|
||||||
|
this.pause();
|
||||||
|
this.rewindToIntro();
|
||||||
this.currentAudioTrackName = trackName;
|
this.currentAudioTrackName = trackName;
|
||||||
const track = this.findTrackNamed(trackName);
|
const track = this.findTrackNamed(trackName);
|
||||||
this.currentAudioTrack = track;
|
this.currentAudioTrack = track;
|
||||||
|
|
@ -184,6 +203,72 @@ export const useTrackStore = defineStore("track", {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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)
|
// Delegate fetching/decoding to AudioEngine (it has caching)
|
||||||
async fetchAudioBuffer(
|
async fetchAudioBuffer(
|
||||||
url: string,
|
url: string,
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1,134 @@
|
||||||
@font-face {
|
|
||||||
font-family: "3270";
|
|
||||||
src: url("./assets/fonts/3270-Regular.woff") format("woff");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@import "./reset.css" layer(base);
|
|
||||||
@import "tailwindcss" prefix(tw);
|
@import "tailwindcss" prefix(tw);
|
||||||
|
@import "./reset.css";
|
||||||
|
|
||||||
@layer base {
|
* {
|
||||||
* {
|
--main-background-color: #28282e;
|
||||||
--main-background-color: #28282e;
|
--inactive-text-color: #909090;
|
||||||
--inactive-text-color: #909090;
|
--active-text-color: #ffffff;
|
||||||
--active-text-color: #ffffff;
|
|
||||||
|
|
||||||
--view-separator-color: #090909;
|
--view-separator-color: #090909;
|
||||||
--view-separator-border: 1px solid var(--view-separator-color);
|
--view-separator-border: 1px solid var(--view-separator-color);
|
||||||
|
|
||||||
--header-background-color: #17181a;
|
--header-background-color: #17181a;
|
||||||
--toolbar-background-color: #212126;
|
--toolbar-background-color: #212126;
|
||||||
--view-background-color: #212126;
|
--view-background-color: #212126;
|
||||||
--card-background-color: #2a2a2d;
|
--card-background-color: #2a2a2d;
|
||||||
--card-border-color: #000000;
|
--card-border-color: #000000;
|
||||||
--card-border-width: 1px;
|
--card-border-width: 1px;
|
||||||
--card-border-radius: 4px;
|
--card-border-radius: 4px;
|
||||||
--card-border: var(--card-border-width) solid var(--card-border-color);
|
--card-border: var(--card-border-width) solid var(--card-border-color);
|
||||||
--card-separator-color: #212126;
|
--card-separator-color: #212126;
|
||||||
--card-separator-width: 2px;
|
--card-separator-width: 2px;
|
||||||
--card-outline-color: #929292;
|
--card-outline-color: #929292;
|
||||||
--card-outline-selected-color: #fa5b4a;
|
--card-outline-selected-color: #fa5b4a;
|
||||||
--card-min-width: 24rem;
|
--card-min-width: 24rem;
|
||||||
|
|
||||||
--input-background-color: #1f1f1f;
|
--input-background-color: #1f1f1f;
|
||||||
--input-outline-color: #070707;
|
--input-outline-color: #070707;
|
||||||
/* disabled */
|
--input-outline-selected-color: #e64b3d;
|
||||||
--input-disabled-text-color: #525256;
|
--input-border-width: 1px;
|
||||||
--input-disabled-background-color: #242428;
|
--input-border-radius: 4px;
|
||||||
--input-disabled-outline-color: #1a1a1d;
|
|
||||||
/* selected */
|
|
||||||
--input-selected-outline-color: #e64b3d;
|
|
||||||
--input-border-width: 1px;
|
|
||||||
--input-border-radius: 4px;
|
|
||||||
--input-selection-color: #4b4b4b;
|
|
||||||
|
|
||||||
--timeline-background-color: var(--main-background-color);
|
--timeline-background-color: var(--main-background-color);
|
||||||
--timeline-background-top-color: #18181e;
|
--timeline-background-top-color: #18181e;
|
||||||
--timeline-border-top-color: var(--view-separator-color);
|
--timeline-border-top-color: var(--view-separator-color);
|
||||||
--timeline-header-separator-color: #000000;
|
--timeline-header-separator-color: #000000;
|
||||||
--timeline-header-tick-edge-color: #2f3036;
|
--timeline-header-tick-edge-color: #2f3036;
|
||||||
/*
|
/*
|
||||||
track layout:
|
track layout:
|
||||||
border-top
|
border-top
|
||||||
...track content...
|
...track content...
|
||||||
border-bottom
|
border-bottom
|
||||||
--- border (separator) ---
|
--- border (separator) ---
|
||||||
border-top
|
border-top
|
||||||
...track content...
|
...track content...
|
||||||
border-bottom
|
border-bottom
|
||||||
*/
|
*/
|
||||||
--timeline-track-border-color: #00000080;
|
--timeline-track-border-color: #00000080;
|
||||||
--timeline-track-border: 1px solid var(--timeline-track-border-color);
|
--timeline-track-border: 1px solid var(--timeline-track-border-color);
|
||||||
--timeline-track-border-top-color: #00000033;
|
--timeline-track-border-top-color: #00000033;
|
||||||
--timeline-track-border-top: 1px solid var(--timeline-track-border-top-color);
|
--timeline-track-border-top: 1px solid var(--timeline-track-border-top-color);
|
||||||
--timeline-track-border-bottom-color: #0000003a;
|
--timeline-track-border-bottom-color: #0000003a;
|
||||||
--timeline-track-border-bottom: 1px solid var(--timeline-track-border-bottom-color);
|
--timeline-track-border-bottom: 1px solid var(--timeline-track-border-bottom-color);
|
||||||
--timeline-text-color: #909090;
|
--timeline-text-color: #909090;
|
||||||
--timeline-bar-color: #fffff0;
|
--timeline-bar-color: #fffff0;
|
||||||
--timeline-bar-opacity: 11%;
|
--timeline-bar-opacity: 11%;
|
||||||
--timeline-bar-width: 1px;
|
--timeline-bar-width: 1px;
|
||||||
|
|
||||||
--timeline-playhead-color: #e64b3d;
|
--timeline-playhead-color: #e64b3d;
|
||||||
/* TODO: playhead color has some transparency, which is hard to calculate */
|
|
||||||
--timeline-playhead-color-1: #000000c0;
|
|
||||||
--timeline-playhead-color-2: #00000059;
|
|
||||||
--timeline-playhead-pressed-color: #e85c4f;
|
|
||||||
|
|
||||||
--timeline-marker-beat-color: #ffffff1c;
|
--timeline-marker-beat-color: #ffffff1c;
|
||||||
|
|
||||||
/* See ./lib/colors/markers.ts */
|
--timeline-clip-border-color: #15151580;
|
||||||
--timeline-marker-color-blue: #007fe3;
|
--timeline-clip-border-color-inner: #151515;
|
||||||
--timeline-marker-color-cyan: #00ced0;
|
--timeline-clip-border-radius: 4px;
|
||||||
--timeline-marker-color-green: #00ad00;
|
|
||||||
--timeline-marker-color-yellow: #f09d00;
|
|
||||||
--timeline-marker-color-red: #e12401;
|
|
||||||
--timeline-marker-color-pink: #ff44c8;
|
|
||||||
--timeline-marker-color-purple: #9013fe;
|
|
||||||
--timeline-marker-color-fuchsia: #c02e6f;
|
|
||||||
--timeline-marker-color-rose: #ffa1b9;
|
|
||||||
--timeline-marker-color-lavender: #a193c8;
|
|
||||||
--timeline-marker-color-sky: #a193c8;
|
|
||||||
--timeline-marker-color-mint: #72db00;
|
|
||||||
--timeline-marker-color-lemon: #dce95a;
|
|
||||||
--timeline-marker-color-sand: #c4915e;
|
|
||||||
--timeline-marker-color-cocoa: #6e5143;
|
|
||||||
--timeline-marker-color-cream: #f5ebe1;
|
|
||||||
|
|
||||||
--timeline-clip-border-color: #15151580;
|
/*
|
||||||
--timeline-clip-border-color-inner: #151515;
|
* TODO:
|
||||||
--timeline-clip-border-radius: 4px;
|
* 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;
|
||||||
* TODO:
|
--timeline-clip-color-apricot: #ffa833;
|
||||||
* timeline clip selected outline:
|
--timeline-clip-color-yellow: #d4ad1f;
|
||||||
* inner 1px black
|
--timeline-clip-color-lime: #9fc615;
|
||||||
* outer 2px red
|
--timeline-clip-color-olive: #5f9921;
|
||||||
*/
|
--timeline-clip-color-green: #448f65;
|
||||||
--timeline-clip-outline-selected-color: #e64b3d;
|
--timeline-clip-color-teal: #019899;
|
||||||
--timeline-clip-outline-selected-width: 2px;
|
--timeline-clip-color-navy: #005278;
|
||||||
--timeline-clip-outline-selected: var(--timeline-clip-outline-selected-width) solid var(--timeline-clip-outline-selected-color);
|
--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;
|
||||||
|
|
||||||
/* See ./lib/colors/clips.ts */
|
--timeline-clip-label-background-color: #00000099;
|
||||||
--timeline-clip-color-orange: #eb6e01;
|
--timeline-clip-label-border-color: #00000060;
|
||||||
--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-baseline-color: #00000033;
|
||||||
--timeline-clip-label-border-color: #00000060;
|
|
||||||
|
|
||||||
--timeline-clip-baseline-color: #00000033;
|
|
||||||
|
|
||||||
--player-background-color: #1a1a1a;
|
|
||||||
|
|
||||||
--inspector-section-separator-color: #1e1e1e;
|
|
||||||
|
|
||||||
--control-button-text-color: #929292;
|
|
||||||
--control-button-background-color: var(--main-background-color);
|
|
||||||
--control-button-border-color: #43474d;
|
|
||||||
/* active */
|
|
||||||
--control-button-active-text-color: #ffffff;
|
|
||||||
--control-button-active-background-color: #17181a;
|
|
||||||
--control-button-active-border-color: #ffffff;
|
|
||||||
/* disabled */
|
|
||||||
--control-button-disabled-text-color: #525256;
|
|
||||||
--control-button-disabled-border-color: #33343b;
|
|
||||||
}
|
|
||||||
|
|
||||||
: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%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
:root {
|
||||||
--font-3270: "3270", monospace;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
--font-mono: "3270", monospace;
|
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 {
|
@layer utilities {
|
||||||
|
|
@ -201,10 +143,6 @@
|
||||||
.scrollbar-none {
|
.scrollbar-none {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-icon-shadow {
|
|
||||||
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|
@ -213,150 +151,3 @@
|
||||||
border-radius: var(--card-border-radius);
|
border-radius: var(--card-border-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.input-text {
|
|
||||||
--input-text-color: var(--active-text-color);
|
|
||||||
color: var(--input-text-color);
|
|
||||||
background-color: var(--input-background-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
outline: var(--input-border-width) solid var(--input-outline-color);
|
|
||||||
|
|
||||||
& input,
|
|
||||||
& textarea {
|
|
||||||
padding: 1px 2px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(input:disabled),
|
|
||||||
&:has(textarea:disabled),
|
|
||||||
&:disabled {
|
|
||||||
--input-text-color: var(--input-disabled-text-color);
|
|
||||||
--input-background-color: var(--input-disabled-background-color);
|
|
||||||
--input-outline-color: var(--input-disabled-outline-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(input:not(:disabled):read-write:focus),
|
|
||||||
&:has(textarea:not(:disabled):read-write:focus) {
|
|
||||||
outline-color: var(--input-selected-outline-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection background for text inside input-text containers */
|
|
||||||
& ::selection,
|
|
||||||
& input::selection,
|
|
||||||
& textarea::selection {
|
|
||||||
background-color: var(--input-selection-color);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
& ::-moz-selection,
|
|
||||||
& input::-moz-selection,
|
|
||||||
& textarea::-moz-selection {
|
|
||||||
background-color: var(--input-selection-color);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.input-text,
|
|
||||||
textarea.input-text {
|
|
||||||
padding: 1px 2px;
|
|
||||||
|
|
||||||
&:not(:disabled):read-write:focus,
|
|
||||||
&:not(:disabled):read-write:focus {
|
|
||||||
outline-color: var(--input-selected-outline-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::selection {
|
|
||||||
background-color: var(--input-selection-color);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-selection {
|
|
||||||
background-color: var(--input-selection-color);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-number,
|
|
||||||
input.input-number {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.control-label {
|
|
||||||
&.control-label__disabled {
|
|
||||||
color: var(--inactive-text-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.control-button {
|
|
||||||
@apply tw:inline-flex tw:items-center tw:justify-center;
|
|
||||||
@apply tw:gap-1 tw:px-2 tw:py-0 tw:min-w-24;
|
|
||||||
|
|
||||||
color: var(--control-button-text-color);
|
|
||||||
border-radius: 1em;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--control-button-border-color);
|
|
||||||
background-color: var(--control-button-background-color);
|
|
||||||
|
|
||||||
& > .control-button__icon {
|
|
||||||
@apply tw:flex-none tw:w-4 tw:h-4 tw:fill-current;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .control-button__text {
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
--control-button-border-color: var(--control-button-active-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:disabled) {
|
|
||||||
@variant hover {
|
|
||||||
--control-button-text-color: var(--control-button-active-text-color);
|
|
||||||
--control-button-border-color: var(--control-button-active-border-color);
|
|
||||||
}
|
|
||||||
@variant active {
|
|
||||||
--control-button-text-color: var(--control-button-active-text-color);
|
|
||||||
--control-button-border-color: var(--control-button-active-border-color);
|
|
||||||
@variant hover {
|
|
||||||
--control-button-background-color: var(--control-button-active-background-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@variant disabled {
|
|
||||||
--control-button-text-color: var(--control-button-disabled-text-color);
|
|
||||||
--control-button-border-color: var(--control-button-disabled-border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.control-select {
|
|
||||||
--input-text-color: var(--active-text-color);
|
|
||||||
color: var(--input-text-color);
|
|
||||||
background-color: var(--input-background-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
outline: var(--input-border-width) solid var(--input-outline-color);
|
|
||||||
|
|
||||||
padding: 1px 16px;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
--input-text-color: var(--input-disabled-text-color);
|
|
||||||
--input-background-color: var(--input-disabled-background-color);
|
|
||||||
--input-outline-color: var(--input-disabled-outline-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* not sure about this */
|
|
||||||
&:not(:disabled):focus {
|
|
||||||
outline-color: var(--input-selected-outline-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import { render } from "vitest-browser-vue";
|
||||||
import SearchField from "@/components/SearchField.vue";
|
import SearchField from "@/components/SearchField.vue";
|
||||||
|
|
||||||
test("default placeholder", async () => {
|
test("default placeholder", async () => {
|
||||||
const { getByRole } = render(SearchField, {
|
const { getByRole } = render(SearchField);
|
||||||
props: { modelValue: "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchBox = getByRole("searchbox");
|
const searchBox = getByRole("searchbox");
|
||||||
await expect.element(searchBox).toBeInTheDocument();
|
await expect.element(searchBox).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
import vueDevTools from "vite-plugin-vue-devtools";
|
import vueDevTools from "vite-plugin-vue-devtools";
|
||||||
import svgLoader from "vite-svg-loader";
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
base: "/muzika-gromche",
|
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
|
|
@ -39,9 +39,29 @@ export default defineConfig({
|
||||||
"@": resolve(__dirname, "./src/"),
|
"@": resolve(__dirname, "./src/"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
base: "/muzika-gromche",
|
||||||
css: {
|
css: {
|
||||||
modules: {
|
modules: {
|
||||||
localsConvention: "camelCaseOnly",
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import base from "./vite.config";
|
|
||||||
import { mergeConfig } from "vite";
|
|
||||||
import { playwright } from "@vitest/browser-playwright";
|
|
||||||
|
|
||||||
export default mergeConfig(base, {
|
|
||||||
define: {
|
|
||||||
// See https://github.com/vitest-dev/vitest/issues/6872
|
|
||||||
"process.env": JSON.stringify({}),
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -992,8 +992,8 @@ namespace MuzikaGromche
|
||||||
public readonly record struct Easing(string Name, Func<float, float> Eval)
|
public readonly record struct Easing(string Name, Func<float, float> Eval)
|
||||||
{
|
{
|
||||||
public static Easing Linear = new("Linear", static x => x);
|
public static Easing Linear = new("Linear", static x => x);
|
||||||
public static Easing InCubic = new("InCubic", static x => x * x * x);
|
|
||||||
public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f));
|
public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f));
|
||||||
|
public static Easing InCubic = new("InCubic", static x => x * x * x);
|
||||||
public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f);
|
public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f);
|
||||||
public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f));
|
public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f));
|
||||||
public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x));
|
public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x));
|
||||||
|
|
|
||||||