1
0
Fork 0

Compare commits

..

1 Commits

Author SHA1 Message Date
ivan tkachenko aa489eb305 WIP: Add frontend web app player & editor in Vue 3 + Vite
TODO:
- implement viewing & editing.
- Add links to deployment, and CHANGELOG.
- Change web app icon.
2025-11-27 16:57:28 +02:00
84 changed files with 1610 additions and 4297 deletions

View File

@ -41,4 +41,4 @@ pnpm run test
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/`.

View File

@ -2,9 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/icon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Muzika Gromche — The ultimate Jester party music mod</title>
</head>

View File

@ -5,8 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.js",
"build": "npm run prebuild && vue-tsc -b && vite build",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"coverage": "vitest run --coverage",
@ -14,39 +13,36 @@
},
"dependencies": {
"@material-design-icons/svg": "^0.14.15",
"@tailwindcss/vite": "^4.1.17",
"@vueuse/core": "^14.1.0",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.17",
"vue": "^3.5.25",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vitest/browser-playwright": "^4.0.15",
"@vitest/coverage-v8": "4.0.14",
"@types/node": "^24.6.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/coverage-v8": "4.0.10",
"@vue/tsconfig": "^0.8.1",
"eslint": "~9.39.1",
"eslint-plugin-vue": "~10.5.1",
"sharp": "^0.33.5",
"png-to-ico": "^3.0.1",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.1.14",
"vite-plugin-vue-devtools": "^8.0.5",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-svg-loader": "^5.1.0",
"vitest": "^4.0.15",
"vitest": "^4.0.10",
"vitest-browser-vue": "^2.0.1",
"vue-tsc": "^3.1.5"
"vue-tsc": "^3.1.0"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
},
"onlyBuiltDependencies": [
"core-js",
"sharp"
"core-js"
]
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -1,50 +0,0 @@
import sharp from 'sharp';
import toIco from 'png-to-ico';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourceIcon = path.resolve(__dirname, '../../icon.png');
const outputDir = path.resolve(__dirname, '../public');
async function generateIcons() {
await fs.mkdir(outputDir, { recursive: true });
// Generate PNGs
const sizes = [32, 192, 256];
for (const size of sizes) {
const outputPath = path.join(outputDir, `icon-${size}.png`);
await sharp(sourceIcon)
.resize(size, size)
.toFile(outputPath);
console.log(`Generated ${outputPath}`);
}
// Generate apple-touch-icon
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png');
await sharp(sourceIcon)
.resize(180, 180)
.toFile(appleIconPath);
console.log(`Generated ${appleIconPath}`);
// Generate favicon.ico
const icoSizes = [16, 24, 32, 48];
const buffers = await Promise.all(icoSizes.map(size =>
sharp(sourceIcon)
.resize(size, size)
.png()
.toBuffer()
));
const icoBuffer = await toIco(buffers);
const icoPath = path.join(outputDir, 'favicon.ico');
await fs.writeFile(icoPath, icoBuffer);
console.log(`Generated ${icoPath}`);
}
generateIcons().catch(err => {
console.error(err);
process.exit(1);
});

View File

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

View File

@ -9,470 +9,66 @@
- exposes getPosition() to read current playback time relative to intro start
*/
import { type AudioTrack, useWrapTime, wrapTimeFn } from "@/lib/AudioTrack";
import type { Seconds } from "@/lib/units";
import {
type ConfigurableWindow,
tryOnScopeDispose,
useRafFn,
useThrottleFn,
watchImmediate,
} from "@vueuse/core";
import {
type MaybeRefOrGetter,
type Ref,
shallowRef,
toValue,
watch,
} from "vue";
export const VOLUME_MAX: number = 1.5;
interface PlayerHandle {
/**
* The `stop()` method schedules a sound to cease playback at the specified time.
*/
stop: (when?: Seconds) => void;
}
interface AudioTrackBuffersHandle extends PlayerHandle {
/**
* Time in AudioContext coordinate system of a moment which lines up with the start of the intro audio buffer.
* If the startPosition was greater than zero, this time is already in the past when the function returns.
*/
readonly introStartTime: Seconds;
}
/**
* Start playing intro + loop buffers at given position.
*
* @returns Handle with introStartTime and stop() method.
*/
function playAudioTrackBuffers(
audioCtx: AudioContext,
destinationNode: AudioNode,
audioTrack: AudioTrack,
/**
* Position in seconds from the start of the intro
*/
startPosition: Seconds = 0,
): AudioTrackBuffersHandle {
const now = audioCtx.currentTime;
const introBuffer = audioTrack.loadedIntro!;
const loopBuffer = audioTrack.loadedLoop!;
const introDuration = introBuffer.duration;
const loopDuration = loopBuffer.duration;
const wrapper = wrapTimeFn(audioTrack);
startPosition = wrapper(startPosition);
let currentIntro: AudioBufferSourceNode | null;
let currentLoop: AudioBufferSourceNode | null;
let introStartTime: Seconds;
// figure out where to start
if (startPosition < introDuration) {
// start intro with offset, schedule loop after remaining intro time
const introOffset = startPosition;
const timeUntilLoop = introDuration - introOffset;
const introNode = audioCtx.createBufferSource();
introNode.buffer = introBuffer;
introNode.connect(destinationNode);
introNode.start(now, introOffset);
const loopNode = audioCtx.createBufferSource();
loopNode.buffer = loopBuffer;
loopNode.loop = true;
loopNode.connect(destinationNode);
loopNode.start(now + timeUntilLoop, 0);
currentIntro = introNode;
currentLoop = loopNode;
introStartTime = now - startPosition;
} else {
// start directly in loop with proper offset into loop
const loopOffset = (startPosition - introDuration) % loopDuration;
const loopNode = audioCtx.createBufferSource();
loopNode.buffer = loopBuffer;
loopNode.loop = true;
loopNode.connect(destinationNode);
loopNode.start(now, loopOffset);
currentIntro = null;
currentLoop = loopNode;
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
// introStartTime = now - introDuration - loopOffset;
introStartTime = now - startPosition;
}
function stop(when?: Seconds) {
try {
currentIntro?.stop(when);
} catch (e) {
/* ignore */
}
try {
currentLoop?.stop(when);
} catch (e) {
/* ignore */
}
currentIntro = null;
currentLoop = null;
}
return { introStartTime, stop };
}
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
playerResult: Omit<T, "stop">;
}
/**
* 25 ms for fade-in/fade-out
*/
const DEFAULT_FADE_DURATION = 0.025;
/**
* Wrap the given player function with a Gain node. Applies fade in effect on start and fade out on stop.
*
* @returns Handle with introStartTime and stop() method.
*/
function playWithFadeInOut<T extends PlayerHandle>(
audioCtx: AudioContext,
destinationNode: AudioNode,
player: (destinationNode: AudioNode) => T,
/**
* Duration of fade in/out in seconds. Fade out extends past the stop() call.
*/
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
): PlayWithFadeInOut<T> {
const GAIN_MIN = 0.0001;
const GAIN_MAX = 1.0;
const fadeGain = audioCtx.createGain();
fadeGain.connect(destinationNode);
fadeGain.gain.value = GAIN_MIN;
const playerHandle = player(fadeGain);
// fade in
const now = audioCtx.currentTime;
const fadeEnd = now + fadeDuration;
fadeGain.gain.setValueAtTime(GAIN_MIN, now);
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd);
// TODO: setTimeout to actually stop after `when`?
function stop(_when?: Seconds) {
// fade out
const now = audioCtx.currentTime;
const fadeEnd = now + fadeDuration;
fadeGain.gain.cancelScheduledValues(now);
fadeGain.gain.setValueAtTime(GAIN_MAX, now);
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd);
playerHandle.stop(fadeEnd);
}
return { playerResult: playerHandle, stop };
}
/**
* Properties relates to the state of playback.
*/
export interface PlaybackState {
/**
* Readonly reference to whether audio is currently playing.
*/
readonly isPlaying: Readonly<Ref<boolean>>;
/**
* Readonly reference to the last remembered start-of-playback position.
*
* Will only update if stop(rememberPosition=true) or seek() is called.
*/
readonly startPosition: Readonly<Ref<Seconds>>;
/**
* Returns current playback position in seconds based on AudioContext time.
*
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
*/
getCurrentPosition(): Seconds;
}
export interface StopOptions {
/**
* If true, update remembered playback position to current position, otherwise revert to last remembered one.
*
* Defaults to false.
*/
rememberPosition?: boolean;
}
export interface SeekOptions {
/**
* If scrub is requested, plays a short sample at that position.
*
* Defaults to false.
*/
scrub?: boolean;
// TODO: optionally keep playing after seeking?
}
/**
* Player controls and properties relates to the state of playback.
*/
export interface PlayerControls {
/**
* Start playing audio buffers from the last remembered position.
*/
play: () => void;
/**
* Stop playing audio buffers.
*
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
*/
stop: (options?: StopOptions) => void;
/**
* Seek to given position in seconds.
*
* - Stop the playback.
* - If scrub is requested, plays a short sample at that position.
*/
seek: (position: Seconds, options?: SeekOptions) => void;
/**
* Properties relates to the state of playback.
*/
readonly playback: PlaybackState;
}
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
}
function reusableAudioBuffersTrackPlayer(
audioCtx: AudioContext,
destinationNode: AudioNode,
audioTrack: AudioTrack,
): ReusableAudioBuffersTrackPlayer {
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null;
const isPlaying = shallowRef(false);
const wrapper = wrapTimeFn(audioTrack);
const startPosition = useWrapTime(audioTrack, 0);
function play() {
if (currentHandle) {
return;
}
currentHandle = playWithFadeInOut(
audioCtx,
destinationNode,
(destinationNode) =>
playAudioTrackBuffers(
audioCtx,
destinationNode,
audioTrack,
startPosition.value,
),
);
isPlaying.value = true;
}
function stop(options?: { rememberPosition?: boolean }) {
const {
rememberPosition = false,
} = options ?? {};
if (currentHandle) {
isPlaying.value = false;
if (rememberPosition) {
startPosition.value = getCurrentPosition();
}
// stop and discard current handle
currentHandle.stop();
currentHandle = null;
}
}
function seek(seekPosition: Seconds, options?: SeekOptions) {
const {
scrub = false,
} = options ?? {};
stop({ rememberPosition: false });
startPosition.value = seekPosition;
if (scrub) {
doThrottledScrub();
}
}
// Scrub is subject to debouncing/throttling, so it doesn't start
// playing samples too often before previous ones could stop.
const doThrottledScrub = useThrottleFn(() => {
// play a short sample at the seeked position
const scrubHandle = playWithFadeInOut(
audioCtx,
destinationNode,
(destinationNode) =>
playAudioTrackBuffers(
audioCtx,
destinationNode,
audioTrack,
startPosition.value,
),
0.01, // short fade of 10 ms
);
setTimeout(() => {
scrubHandle.stop(0.01);
}, 80); // stop after N ms
}, 80);
function getCurrentPosition(): Seconds {
if (!currentHandle) {
return startPosition.value;
}
const elapsed = audioCtx.currentTime -
currentHandle.playerResult.introStartTime;
return wrapper(elapsed);
}
return {
play,
stop,
seek,
playback: {
isPlaying,
startPosition,
getCurrentPosition,
},
};
}
interface LivePlaybackPositionOptions extends ConfigurableWindow {
}
interface LivePlaybackPositionReturn {
stop: () => void;
position: Readonly<Ref<Seconds>>;
}
export function useLivePlaybackPosition(
playback: MaybeRefOrGetter<PlaybackState | null>,
options?: LivePlaybackPositionOptions,
): LivePlaybackPositionReturn {
const cleanups: Function[] = [];
const cleanup = () => {
cleanups.forEach((fn) => fn());
cleanups.length = 0;
};
const getPosition = () => {
return toValue(playback)?.getCurrentPosition() ?? 0;
};
const position = shallowRef<Seconds>(getPosition());
const updatePosition = () => {
position.value = getPosition();
};
const raf = useRafFn(() => {
updatePosition();
}, {
...options,
immediate: false,
once: false,
});
const stopWatch = watchImmediate(() => [
toValue(playback),
], ([playback]) => {
cleanup();
updatePosition();
if (!playback) return;
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
if (isPlaying) {
raf.resume();
} else {
raf.pause();
updatePosition();
}
}));
cleanups.push(watch(playback.startPosition, () => {
raf.pause();
updatePosition();
if (playback.isPlaying.value) {
raf.resume();
}
}));
cleanups.push(() => raf.pause());
});
const stop = () => {
stopWatch();
cleanup();
};
tryOnScopeDispose(cleanup);
return { stop, position };
}
export function togglePlayStop(
player: PlayerControls | null,
options?: StopOptions,
) {
if (!player) return;
if (player.playback.isPlaying.value) {
player.stop(options);
} else {
player.play();
}
}
class AudioEngine {
audioCtx: AudioContext | null = null;
masterGain: GainNode | null = null; // controlled by UI volume slider
// fadeGain: GainNode | null = null; // tiny fade to avoid clicks
fadeGain: GainNode | null = null; // tiny fade to avoid clicks
// cache of decoded buffers by URL
bufferCache = new Map<string, AudioBuffer>();
private _player: Ref<PlayerControls | null> = shallowRef(null);
// readonly player: Readonly<Ref<PlayerControls | null>> = this._player;
// currently playing nodes
currentIntro: AudioBufferSourceNode | null = null;
currentLoop: AudioBufferSourceNode | null = null;
introDuration: number | undefined = undefined;
loopDuration: number | undefined = undefined;
// timing bookkeeping
introStartTime: number | undefined = undefined; // audioCtx.currentTime when intro started
playedDuration: number | undefined = undefined; // seconds elapsed at pause
stoppingTimeoutID: number | undefined = undefined; // running setTimeout for fade out and cleanup
shuttingDown: boolean = false;
// settings
fadeDuration = 0.025; // 25 ms for fade-in/fade-out
init() {
if (this.shuttingDown) return;
if (this.audioCtx) return;
this.audioCtx =
new (window.AudioContext || (window as any).webkitAudioContext)();
this.masterGain = this.audioCtx.createGain();
this.fadeGain = this.audioCtx.createGain();
// routing: sources -> fadeGain -> masterGain -> destination
this.fadeGain.connect(this.masterGain);
this.masterGain.connect(this.audioCtx.destination);
// default full volume
this.masterGain.gain.value = 1;
this.fadeGain.gain.value = 1;
this.currentIntro = null;
this.currentLoop = null;
this.introStartTime = undefined;
this.playedDuration = undefined;
if (this.stoppingTimeoutID !== undefined) {
clearTimeout(this.stoppingTimeoutID);
this.stoppingTimeoutID = undefined;
}
if (this.shuttingDown) {
this.shuttingDown = false;
}
}
shutdown() {
this.stopPlayer();
this.audioCtx?.close();
this.audioCtx = null;
this.masterGain = null;
this.pause({ shutdown: true });
}
async fetchAudioBuffer(
@ -502,29 +98,145 @@ class AudioEngine {
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05);
}
initPlayer(
audioTrack: AudioTrack,
): PlayerControls | null {
this.init();
if (!this.audioCtx || !this.masterGain) return null;
this.stopPlayer();
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) return null;
const player = reusableAudioBuffersTrackPlayer(
this.audioCtx,
this.masterGain,
audioTrack,
);
this._player.value = player;
return player;
private fadeOutNow(fade = this.fadeDuration) {
if (!this.audioCtx || !this.fadeGain) return;
const now = this.audioCtx.currentTime;
const end = now + fade;
this.fadeGain.gain.cancelScheduledValues(now);
this.fadeGain.gain.setValueAtTime(this.fadeGain.gain.value, now);
this.fadeGain.gain.linearRampToValueAtTime(0.0001, end);
}
private stopPlayer() {
if (this._player.value) {
this._player.value.stop();
this._player.value = null;
private fadeInNow(fade = this.fadeDuration) {
if (!this.audioCtx || !this.fadeGain) return;
const now = this.audioCtx.currentTime;
const end = now + fade;
this.fadeGain.gain.cancelScheduledValues(now);
this.fadeGain.gain.setValueAtTime(0.0001, now);
this.fadeGain.gain.linearRampToValueAtTime(1, end);
}
// Play intro then seamlessly transition to loop (loop=true).
// Normally tracks have an intro buffer and a loop buffer. If the requested
// start offset falls after the intro duration, we skip playing the intro
// and start the loop immediately with a calculated offset into the loop.
// offset = seconds into the composite timeline (intro + loop) where playback should start
playBuffers(introBuffer: AudioBuffer, loopBuffer: AudioBuffer, position = 0) {
if (this.shuttingDown) return;
this.init();
if (!this.audioCtx || !this.fadeGain) return;
// stop any previous nodes
this.stopNodes();
// abort the clean up after fade out which would've stopped our newly created nodes
clearTimeout(this.stoppingTimeoutID);
this.stoppingTimeoutID = undefined;
const now = this.audioCtx.currentTime;
this.introDuration = introBuffer.duration;
this.loopDuration = loopBuffer.duration;
// figure out where to start
if (position < this.introDuration) {
// start intro with offset, schedule loop after remaining intro time
const introOffset = position;
const timeUntilLoop = this.introDuration - introOffset;
const introNode = this.audioCtx.createBufferSource();
introNode.buffer = introBuffer;
introNode.connect(this.fadeGain);
introNode.start(now, introOffset);
const loopNode = this.audioCtx.createBufferSource();
loopNode.buffer = loopBuffer;
loopNode.loop = true;
loopNode.connect(this.fadeGain);
loopNode.start(now + timeUntilLoop, 0);
this.currentIntro = introNode;
this.currentLoop = loopNode;
this.introStartTime = now - position;
} else {
// start directly in loop with proper offset into loop
const loopOffset = (position - this.introDuration) % this.loopDuration;
const loopNode = this.audioCtx.createBufferSource();
loopNode.buffer = loopBuffer;
loopNode.loop = true;
loopNode.connect(this.fadeGain!);
loopNode.start(now, loopOffset);
this.currentIntro = null;
this.currentLoop = loopNode;
this.introStartTime = now - this.introDuration - loopOffset;
}
// brief fade to avoid clicks
this.fadeInNow();
this.playedDuration = undefined;
}
pause({ shutdown = false }: { shutdown?: boolean } = {}) {
if (!this.audioCtx) return;
if (this.shuttingDown) return;
this.shuttingDown = shutdown;
// capture played duration at stop time before the fade
// (the fade is just an effect which does not affect core timing logic)
this.playedDuration = this.getPosition();
this.introStartTime = undefined;
// fade quickly then stop
this.fadeOutNow();
// schedule stop slightly after fade to avoid cutting off
const stopAfterMs = (this.fadeDuration + 0.005) * 1000;
// clean up after the fade, but leave a hatch to abort the clean up
this.stoppingTimeoutID = setTimeout(() => {
this.stopNodes();
if (this.shuttingDown) {
this.stopEngine();
}
}, stopAfterMs);
}
private stopNodes() {
try {
this.currentIntro?.stop();
} catch (e) {
/* ignore */
}
try {
this.currentLoop?.stop();
} catch (e) {
/* ignore */
}
this.currentIntro = null;
this.currentLoop = null;
}
private stopEngine() {
this.audioCtx?.close();
this.audioCtx = null;
this.fadeGain = null;
this.masterGain = null;
this.shuttingDown = false;
}
// Return playback position in seconds relative to intro start (intro=0..introDuration, then loop)
getPosition(): number {
if (!this.audioCtx) return 0;
if (this.introStartTime === undefined) {
// paused, position stays constant
return this.playedDuration ?? 0;
} else {
// make sure playback position stays in bounds: wrap around loop duration
const now = this.audioCtx.currentTime;
let playedDuration = now - this.introStartTime;
if (
this.introDuration !== undefined && this.loopDuration !== undefined &&
playedDuration > this.introDuration + this.loopDuration
) {
playedDuration = this.introDuration + (playedDuration - this.introDuration) % this.loopDuration;
}
return Math.max(0, now - this.introStartTime);
}
}
}

View File

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

View File

@ -26,6 +26,20 @@ function clear() {
</div>
</template>
<style scoped>
.input-text {
background-color: var(--input-background-color);
border-radius: var(--input-border-radius);
outline: var(--input-border-width) solid var(--input-outline-color);
}
.input-text:has(input:focus) {
outline-color: var(--input-outline-selected-color);
}
input:focus {
outline: none;
}
.button {
color: #929292;
transition: color 150ms linear;

View File

@ -1,9 +1,7 @@
<script lang="ts" setup>
import { useTimelineStore } from '@/store/TimelineStore';
import { useTrackStore } from '@/store/TrackStore';
const trackStore = useTrackStore();
const timeline = useTimelineStore();
</script>
<template>
@ -25,7 +23,7 @@ const timeline = useTimelineStore();
Track progress: {{ trackStore.audioTrackProgress }}
</p>
<p>
Playback status: {{ timeline.isPlaying }}
Playback status: {{ trackStore.isPlaying }}
</p>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];

View File

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

View File

@ -75,7 +75,7 @@ const filteredIsEmpty = computed(() => filteredGroupedSortedTracks.value.length
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
<p class="tw:text-2xl tw:font-bold">No tracks found</p>
</div>
<div v-else
<div v-else ref="scrollContainer"
class="tw:flex-none tw:grid tw:px-8 tw:pb-8 tw:max-sm:px-4 tw:max-sm:pb-4 tw:gap-4 tw:max-sm:columns-1" style="
grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr));
">

View File

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

View File

@ -1,52 +1,32 @@
<script setup lang="ts">
import { computed, useAttrs, useId } from 'vue';
import { computed } from 'vue';
import classes from './ToolBar.module.css';
import type { Orientation } from "./Slider";
const {
min,
max,
step,
defaultValue,
reset,
orientation = "horizontal",
title,
defaultValue = undefined,
} = defineProps<{
min?: number,
max?: number,
step?: number,
orientation?: "horizontal" | "vertical",
defaultValue?: number,
reset?: () => void,
orientation?: Orientation,
title?: string,
}>();
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const isVertical = computed(() => orientation === "vertical");
const orient = computed(() => orientation === "vertical" ? "vertical" : null);
const isVertical = computed(() => orientation === 'vertical');
const model = defineModel<number>();
function dblclickHandler(event: MouseEvent) {
if (reset !== undefined) {
function reset(event: MouseEvent) {
if (defaultValue !== undefined) {
event.preventDefault();
reset();
model.value = defaultValue;
}
}
const markersListId = useId();
</script>
<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="[classes.toolbarControl, isVertical ? 'tw:min-h-10 tw:max-h-40' : 'tw:min-w-10 tw:max-w-40']"
:list="markersListId" v-bind="attrs" />
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
<datalist :id="markersListId">
<option v-if="defaultValue !== undefined" :value="defaultValue"></option>
</datalist>
@dblclick="reset" />
</template>
<style scoped>
.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 {
-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::-webkit-slider-runnable-track,
.slider::-moz-range-track {
-webkit-appearance: none;
appearance: none;
@ -99,57 +63,29 @@ const markersListId = useId();
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 {
width: 4px;
height: 100%;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
border-radius: 50%;
background: radial-gradient(#919191 80%, #212121);
/* unique to -webkit */
width: 12px;
height: 12px;
margin-top: -4px;
}
.slider[orient="vertical"]::-webkit-slider-thumb {
margin-top: 0;
margin-left: -4px;
}
.slider::-webkit-slider-thumb,
.slider::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;
border-radius: 50%;
background: radial-gradient(#919191 80%, #212121);
/* unique to -moz */
width: 8px;
height: 8px;
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);
}
.slider:not(:disabled):active::-moz-range-thumb {
background: radial-gradient(#5e5e5e 40%, #919191 50%, #919191 80%, #212121);
}
.slider:focus-visible::-webkit-slider-thumb {
outline: 4px solid #556cc9;
}
.slider:focus-visible::-webkit-slider-thumb,
.slider:focus-visible::-moz-range-thumb {
outline: 4px solid #556cc9;
}

View File

@ -1,4 +1,4 @@
@reference "tailwindcss";
@import "tailwindcss" prefix(tw);
@layer utilities {
.tool-button {
@ -21,11 +21,11 @@
cursor: pointer;
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 {
&:not(:disabled) {
@apply text-gray-300;
@apply tw:text-gray-300;
}
}
@ -36,8 +36,8 @@
&>svg {
fill: currentColor;
@apply tw:w-12 tw:h-12;
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
@apply w-12 h-12;
}
}
@ -58,7 +58,7 @@
background-color: #2c2c30;
/* 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 {
&:not(:disabled) {
@ -74,7 +74,7 @@
&>svg {
fill: currentColor;
@apply w-4 h-4;
@apply tw:w-4 tw:h-4;
}
}
}

View File

@ -50,10 +50,6 @@ const volumeDisplay = computed<number>({
},
});
function reset() {
volume.value = defaultVolume;
}
const defaultValue = computed(() => toSteps(defaultVolume));
</script>
<template>
@ -66,8 +62,20 @@ const defaultValue = computed(() => toSteps(defaultVolume));
<VolumeDown v-else-if="volume < 0.66" style="transform: translateX(-4px);" />
<VolumeUp v-else :class="{ 'tw:text-[#e8ba3d]': volume > 1.01 }" />
</label>
<Slider :min="0" :max="sliderSteps" :step="1" v-model.number="volumeDisplay" :reset="reset" :defaultValue
title="Volume" />
<Slider min="0" :max="sliderSteps" step="1" v-model.number="volumeDisplay" :defaultValue title="Volume"
list="markers" />
<!-- TODO: markers are not rendered because of overridden style, and they affect snapping essentially overriding steps -->
<!-- list="markers" -->
<datalist id="markers">
<option :value="defaultValue"></option>
<!--
<option value="0"></option>
<option value="4"></option>
<option value="8"></option>
<option value="12"></option>
<option value="16"></option>
-->
</datalist>
</div>
</template>
<style scoped></style>

View File

@ -1,32 +1,95 @@
<script setup lang="ts">
import Slider from '@/components/library/Slider.vue';
import type { UseZoomAxis } from '@/lib/useZoomAxis';
import Add from "@material-design-icons/svg/filled/add.svg";
import Remove from "@material-design-icons/svg/filled/remove.svg";
import type { Orientation } from "./Slider";
import { clamp } from '@vueuse/core';
import { computed } from 'vue';
import ToolButtonSmall from './ToolButtonSmall.vue';
const {
axis,
orientation = "horizontal",
defaultZoom = undefined,
extended = false,
} = defineProps<{
axis: UseZoomAxis,
orientation?: Orientation,
orientation?: "horizontal" | "vertical",
defaultZoom?: number,
extended?: boolean,
}>();
/** Zoom factor from 1 (fit content) to about 10 (content takes up 10x more space than the viewport).
* If extended is set, lower bound will be less than 1 (about 0.5) and upper bound is about 20x.
*/
const zoom = defineModel<number>("zoom", { required: true });
const zoomStepButtons = 10;
const zoomStepSlider = 1;
/* 0..100 or if extended is set -20..100 */
const zoomMin = computed(() => extended ? -2 * zoomStepButtons : 0);
const zoomMax = computed(() => extended ? 200 : 100);
const scale = 16;
// dirty hack because no exp growth: after extended threshold scale is more steep
const scale2 = 8;
const scaleThreshold = 100;
const zoomThreshold = 1 + scaleThreshold / scale;
// zoom: external level 0.5 .. 7.25
// slider value: interval 0 .. 100 or -20 .. 100
function toSliderValue(zoom: number): number {
if (zoom > zoomThreshold) {
return toSliderValue(zoomThreshold) + (zoom - zoomThreshold) * scale2;
}
if (zoom >= 1) {
return (zoom - 1) * scale;
} else {
return -2 * zoomMin.value * (zoom - 1);
}
}
function fromSliderValue(value: number): number {
// dirty hack because no exp growth
if (value > scaleThreshold) {
return fromSliderValue(scaleThreshold) + (value - scaleThreshold) / scale2;
}
if (value >= 0) {
return 1 + value / scale;
} else {
return 1 - value / (2 * zoomMin.value);
}
}
const defaultValue = computed(() => defaultZoom === undefined ? undefined : toSliderValue(defaultZoom));
// Internal integer representation that avoids floating point errors.
const zoomSliderValue = computed<number>({
get() {
return Math.round(toSliderValue(zoom.value));
},
set(value) {
value = clamp(Math.round(value), zoomMin.value, zoomMax.value);
zoom.value = fromSliderValue(value);
},
});
function onButton(direction: number): void {
let val = zoomSliderValue.value - zoomMin.value;
if (val % zoomStepButtons !== 0) {
// go to the nearest full step up or down depending on the direction
val = ((direction > 0) ? Math.ceil : Math.floor)(val / zoomStepButtons);
zoomSliderValue.value = val * zoomStepButtons + zoomMin.value;
} else {
zoomSliderValue.value += direction * zoomStepButtons;
}
}
</script>
<!-- for some reason min-width does not propagate up from Slider -->
<template>
<!-- for some reason min-width does not propagate up from Slider -->
<div class="tw:px-2 tw:flex tw:items-center tw:gap-2"
:class="orientation == 'vertical' ? 'tw:flex-col' : 'tw:flex-row'">
<ToolButtonSmall :icon="Remove" title="Zoom Out" @click="axis.zoomOut" :disabled="axis.isAtMin.value" />
<!-- skip :defaultValue="axis.default.discrete.value" because snapping makes dragging to negative values impossible -->
<Slider :min="axis.min.discrete.value" :max="axis.max.discrete.value" :step="axis.stepSmall.discrete.value"
v-model.number="axis.zoom.discrete.value" :orientation :reset="axis.reset" />
<ToolButtonSmall :icon="Add" title="Zoom In" @click="axis.zoomIn" :disabled="axis.isAtMax.value" />
<ToolButtonSmall :icon="Remove" title="Zoom Out" @click="onButton(-1)" :disabled="zoomSliderValue <= zoomMin" />
<Slider :min="zoomMin" :max="zoomMax" :step="zoomStepSlider" v-model.number="zoomSliderValue" :orientation
:defaultValue />
<ToolButtonSmall :icon="Add" title="Zoom In" @click="onButton(+1)" :disabled="zoomSliderValue >= zoomMax" />
</div>
</template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

@ -51,6 +51,5 @@ const viewportSide = computed(() => timeline.viewportSide(positionSeconds));
top: 0;
will-change: transform, visibility;
/* pointer-events: none; */
user-select: none;
}
</style>

View File

@ -1,18 +1,18 @@
<script setup lang="ts">
import Playhead from '@/components/timeline/Playhead.vue';
// import Timestamp from '@/components/timeline/Timestamp.vue';
// import { dummyAudioTrackForTesting, secondsToBeats } from '@/lib/AudioTrack';
import ZoomSlider from '@/components/library/ZoomSlider.vue';
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
import Playhead from '@/components/timeline/Playhead.vue';
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
import { useTimelineScrubbing } from "@/lib/useTimelineScrubbing";
import { useVeiwportWheel } from '@/lib/useVeiwportWheel';
import type { UseZoomAxis } from '@/lib/useZoomAxis';
import { useOptionalWidgetState, type UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
import { bindTwoWay, toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
import { useElementBounding, useScroll } from '@vueuse/core';
import { DEFAULT_ZOOM_HORIZONTAL, DEFAULT_ZOOM_VERTICAL, useTimelineStore } from '@/store/TimelineStore';
import { useTrackStore } from '@/store/TrackStore';
import { useElementBounding, useEventListener, useScroll } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { useId, useTemplateRef, watch } from 'vue';
import { computed, useId, useTemplateRef, watch } from 'vue';
import TimelineTrackHeader from './TimelineTrackHeader.vue';
import TimelineTrackView from './TimelineTrackView.vue';
import TimelineMarkers from './markers/TimelineMarkers.vue';
@ -23,17 +23,97 @@ const {
rightSidebar: UseOptionalWidgetStateReturn,
}>();
const trackStore = useTrackStore();
const timeline = useTimelineStore();
const audioTrack = computed(() => trackStore.currentAudioTrack!);
const {
headerHeight, sidebarWidth,
viewportZoomHorizontal, viewportZoomVertical,
viewportScrollOffsetTop, viewportScrollOffsetLeft,
contentWidthIncludingEmptySpacePx,
visibleTracks,
} = storeToRefs(timeline);
// nested composable marked with markRaw
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis;
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis;
// const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 3));
const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 10));
// const playbackPositionSeconds = defineModel<number | null>('playbackPositionSeconds', { default: null });
// const playbackPositionBeats = computed<number | null>(() => {
// if (playbackPositionSeconds.value === null) {
// return null;
// }
// return secondsToBeats(audioTrack.value!, playbackPositionSeconds.value);
// });
// const playbackPosition = computed<number>(() => {
// if (playbackPositionSeconds.value === null) {
// return 0;
// }
// return playbackPositionSeconds.value / timelineTotalDurationSeconds.value;
// });
//
// const cursorPositionSeconds = shallowRef<number | null>(0)
// const cursorPositionBeats = computed<number | null>(() => {
// if (cursorPositionSeconds.value === null) {
// return null;
// }
// return secondsToBeats(audioTrack.value!, cursorPositionSeconds.value);
// });
// const cursorPosition = computed<number>(() => {
// if (cursorPositionSeconds.value === null) {
// return 0;
// }
// return cursorPositionSeconds.value / timelineTotalDurationSeconds.value;
// });
// const timelineEl = useTemplateRef('timeline');
//
// function _cursorToPositionSeconds(e: MouseEvent): number | null {
// if (audioTrack.value === null || timelineEl.value === null) {
// return null;
// }
// // clientX is broken in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=505521#c80
// const x = e.pageX - timelineEl.value.offsetLeft;
// const rect = timelineEl.value.getBoundingClientRect();
// const position = x / rect.width;
// const positionClamped = Math.max(0, Math.min(1, position));
// return positionClamped * timelineTotalDurationSeconds.value;
// }
//
// const isDragging = shallowRef(false);
//
// function timelinePointerDown(event: PointerEvent) {
// const tl = timelineEl.value;
// if (tl && !isDragging.value) {
// isDragging.value = true;
// tl.setPointerCapture(event.pointerId);
// timelinePointerMove(event);
// }
// }
// function timelinePointerUp(event: PointerEvent) {
// timelinePointerMove(event);
// if (isDragging.value) {
// isDragging.value = false;
// }
// }
// function timelinePointerMove(event: PointerEvent) {
// // preview mouse position
// console.log("MOVE", cursorPositionSeconds.value);
// cursorPositionSeconds.value = _cursorToPositionSeconds(event);
// if (isDragging.value) {
// // apply mouse position
// playbackPositionSeconds.value = cursorPositionSeconds.value;
// }
// }
// function timelinePointerLeave(_event: PointerEvent) {
// console.log("LEAVE", isDragging.value);
// if (!isDragging.value) {
// cursorPositionSeconds.value = null;
// }
// }
// Questionable thin vertical sidebar on the right, contains vertical zoom slider.
// Not sure I want this to remain, so used a boolean flag to hide.
const timelineScrollGroup = useId();
@ -55,11 +135,36 @@ const {
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop);
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft);
useVeiwportWheel(timelineRootElement, {
axisHorizontal: viewportZoomHorizontal,
axisVertical: viewportZoomVertical,
scrollOffsetLeft: timelineScrollViewOffsetLeft,
});
function scrollZoomHandler(event: WheelEvent) {
// Note: Math.random() prevents console output history from collapsing same entries.
// console.log("WHEEEEEL", Math.random().toFixed(3), event.deltaX, event.deltaY, event.target, event);
// TODO: Ignore Ctrl key because it intercepts touchpad pinch to zoom?
// TODO: what if the user doesn't use a touchpad, and thus has
// no way to scroll horizontally other than by dragging a scrollbar?
const ignoreCtrlWheel = false;
if (event.shiftKey) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
viewportZoomVertical.value -= event.deltaY / 100;
}
else if (event.altKey) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
viewportZoomHorizontal.value -= event.deltaY / 100;
}
else if (event.ctrlKey && !ignoreCtrlWheel) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
viewportScrollOffsetLeft.value += event.deltaY;
}
}
useEventListener(timelineRootElement, "wheel", scrollZoomHandler, { passive: false, });
// Shift+Z - reset zoom
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
@ -67,19 +172,18 @@ onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key ==
event.preventDefault();
});
const scrubbing = useTemplateRef('scrubbing');
useTimelineScrubbing(scrubbing);
</script>
<template>
<div ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
}">
}" style="border-top: var(--view-separator-border);">
<!-- top left corner, contains zoom controls -->
<div class="toolbar-background tw:max-w-full tw:flex tw:flex-row tw:flex-nowrap tw:items-center"
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);">
<ZoomSlider :axis="viewportZoomHorizontal" class="tw:flex-1" />
<ZoomSlider v-model:zoom="viewportZoomHorizontal" :default-zoom="DEFAULT_ZOOM_HORIZONTAL" extended
class="tw:flex-1" />
</div>
@ -98,9 +202,7 @@ useTimelineScrubbing(scrubbing);
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);">
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
<TimelineHeader />
</div>
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
@ -108,6 +210,10 @@ useTimelineScrubbing(scrubbing);
</ScrollSync>
<!-- TODO -->
<!-- <div ref="timeline" class="timeline" @pointerdown="timelinePointerDown" @pointerup="timelinePointerUp"
@pointermove="timelinePointerMove" @pointerleave="timelinePointerLeave"> -->
<!-- timeline content -->
<ScrollSync ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;">
@ -149,8 +255,7 @@ useTimelineScrubbing(scrubbing);
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
style="grid-row: 1 / 3; grid-column: 2;">
<div class="tw:h-full tw:relative tw:overflow-hidden"
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }">
<div class="tw:h-full tw:relative tw:overflow-hidden" :style="{ width: timeline.contentWidthPx }">
<!-- actuals playback position -->
<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"
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>
</template>
<style scoped>
/* .timeline {
background-color: var(--timeline-background-color);
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
} */
.shadow-top,
.shadow-right,
.shadow-bottom,

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { togglePlayStop } from '@/audio/AudioEngine';
import ToolButton from '@/components/library/ToolButton.vue';
import ToolToggle from '@/components/library/ToolToggle.vue';
import Timestamp from '@/components/timeline/Timestamp.vue';
import { useOptionalWidgetState } from '@/lib/useOptionalWidgetState';
import { useTimelineStore } from '@/store/TimelineStore';
import { useTrackStore } from '@/store/TrackStore';
import Pause from '@material-design-icons/svg/outlined/pause_circle.svg';
import Play from '@material-design-icons/svg/outlined/play_circle.svg';
import Replay from '@material-design-icons/svg/outlined/replay.svg';
@ -15,15 +15,13 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import MasterVolumeSlider from './MasterVolumeSlider.vue';
import Timeline from './Timeline.vue';
import Panel from "@/components/library/panel/Panel.vue";
const trackStore = useTrackStore();
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({
visible: useLocalStorage("timeline.rightSidebar.visible", true),
showString: "Show Right Sidebar",
@ -32,47 +30,48 @@ const rightSidebar = useOptionalWidgetState({
});
function rewindToIntro() {
timeline.rewindToIntro();
trackStore.rewindToIntro();
syncPlayheadPosition();
}
function rewindToWindUp() {
timeline.rewindToWindUp();
trackStore.rewindToWindUp();
syncPlayheadPosition();
}
function rewindToLoop() {
timeline.rewindToLoop();
trackStore.rewindToLoop();
syncPlayheadPosition();
}
function toggle() {
togglePlayStop(timeline.player, { rememberPosition: true });
function syncPlayheadPosition() {
timeline.playheadPosition = trackStore.playedDuration;
timeline.ensurePlayheadWithinViewport();
}
</script>
<template>
<Panel class="tw:border-t">
<template #toolbar>
<div class="tw:flex tw:flex-col toolbar-background" style="border-top: var(--view-separator-border);">
<div
class="tw:flex tw:flex-row tw:max-sm:flex-col tw:items-center tw:justify-center tw:gap-x-4 tw:gap-y-2 tw:px-4 tw:max-sm:px-2 tw:py-1">
<div
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:border-(--view-separator-color)">
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:max-sm:border-b-(--view-separator-color)">
<ToolButton :icon="Replay" @click="rewindToIntro" title="Rewind to Intro" />
<ToolButton :icon="Restart" @click="rewindToWindUp"
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'" @click="toggle" />
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'"
@click="trackStore.togglePlayPause()" />
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
</div>
<div class="tw:flex-1 tw:max-sm:w-full tw:flex tw:flex-row tw:gap-x-2">
<Timestamp :seconds="timeline.playheadPosition" :beats="timeline.playheadPositionLoopOffsetBeats" />
<Timestamp :seconds="timeline.playheadPosition" :beats="timeline.playheadPositionBeats" />
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
{{ audioTrack?.Name }}
{{ 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>
</template>
<Timeline class="tw:min-h-0 tw:size-full" :rightSidebar />
</Panel>
<Timeline class="tw:flex-1 tw:min-h-0" :rightSidebar />
</div>
</template>
<style scoped>
.description {

View File

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

View File

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

View File

@ -1,5 +1,8 @@
<script setup lang="ts">
import type { TimelineClipData, TimelineTrackData } from '@/lib/Timeline';
// import { toPx } from '@/lib/vue';
// import { useTimelineStore } from '@/store/TimelineStore';
// import { storeToRefs } from 'pinia';
import Default from './Default.vue';
const {
@ -9,6 +12,9 @@ const {
clip: TimelineClipData,
width: number,
}>();
// const { trackHeight } = storeToRefs(useTimelineStore());
// const color = "#00000080";
</script>
<template>
<div class="tw:absolute tw:w-full fade-out-gradient" />

View File

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

View File

@ -1,6 +1,6 @@
import type { TimelineClipData, TimelineTrackData } from "@/lib/Timeline";
import type { Component } from "vue";
import { Audio, Default, FadeOut, Lyrics, Palette } from "./impl";
import { Default, FadeOut, Lyrics, Palette } from "./impl";
export interface ClipContentViewProps {
track: TimelineTrackData;
@ -13,7 +13,7 @@ export type ClipContentViewComponent = Component<ClipContentViewProps>;
export function getComponentFor(track: TimelineTrackData): ClipContentViewComponent {
switch (track.contentViewType) {
case "audio":
return Audio;
return Default;
case "event":
return Default;
case "fadeout":

View File

@ -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 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 }}
</span>
</div>

View File

@ -1,5 +1,4 @@
<script setup lang="ts">
import MarkerBox from '@/components/timeline/markers/MarkerBox.vue';
import { useTimelineTicksBeats, useTimelineTicksSeconds } from '@/lib/useTimelineTicks';
import { toPx } from '@/lib/vue';
import { useTimelineStore } from '@/store/TimelineStore';
@ -16,22 +15,15 @@ const allTicks = [
</script>
<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) }">
<!-- 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"
:width="ticks.widthPx.value" :label="ticks.label(tick).value" />
</div>
<div class="tw:absolute tw:size-full tw:border-b tw:border-[#252525]"></div>
<!-- header markers -->
<div class="tw:absolute tw:size-full">
<MarkerBox v-for="marker in timeline.markers" :marker />
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,10 +1,5 @@
<script setup lang="ts">
import type { TimelineMarkerData } from '@/lib/Timeline';
import { markerToAbsoluteTime } from '@/lib/Timeline';
import { usePx } from '@/lib/usePx';
import { useTimelineStore } from '@/store/TimelineStore';
import { computed, shallowRef, useTemplateRef } from 'vue';
import MarkerBoxSvg from "./marker-box.svg";
const {
marker,
@ -12,60 +7,12 @@ const {
marker: TimelineMarkerData,
}>();
const timeline = useTimelineStore();
const left = usePx(() => {
const seconds = markerToAbsoluteTime(timeline.audioTrack!, marker);
const px = timeline.secondsToPixels(seconds)
return px;
});
// const referenceClass = computed(() => marker.reference === 'absolute' ? 'marker-box-top' : 'marker-box-bottom');
const positionClass = computed(() => marker.position === 'top' ? 'marker-box-top' : 'marker-box-bottom');
// TODO: selection manager
const selected = shallowRef(false);
function toggle(event: MouseEvent) {
selected.value = !selected.value;
}
const element = useTemplateRef('element');
function onPointerDown(event: PointerEvent) {
element.value?.setPointerCapture(event.pointerId);
}
const left = `15%`;
</script>
<template>
<div ref="element" class="tw:absolute marker-box" :class="[positionClass, { selected }]" :style="{
left: left.string,
color: marker.color,
}" :title="marker.name" @click.prevent.stop="toggle" @pointerdown.prevent.stop="onPointerDown">
<MarkerBoxSvg />
</div>
<div class="tw:absolute tw:w-0 tw:h-full tw:border-l" :style="{
left,
borderColor: marker.color,
}" :label="marker.name" />
</template>
<style scoped>
.marker-box {
position: absolute;
width: 11px;
height: 16px;
transform: translateX(-5px) translateY(1px);
--marker-stroke-color: #00000080;
}
.marker-box-bottom {
bottom: 0;
}
.marker-box-top {
top: 0;
&:deep(svg) {
transform: rotate(180deg);
}
}
.selected {
--marker-stroke-color: #ffffff;
}
</style>
<style scoped></style>

View File

@ -1,8 +1,5 @@
<script setup lang="ts">
import type { TimelineMarkerData } from '@/lib/Timeline';
import { markerToAbsoluteTime } from '@/lib/Timeline';
import { usePx } from '@/lib/usePx';
import { useTimelineStore } from '@/store/TimelineStore';
const {
marker,
@ -10,19 +7,12 @@ const {
marker: TimelineMarkerData,
}>();
const timeline = useTimelineStore();
const left = usePx(() => {
const seconds = markerToAbsoluteTime(timeline.audioTrack!, marker);
const px = timeline.secondsToPixels(seconds)
return px;
});
const left = `${15 + 15 * marker.markerIn}px`;
</script>
<template>
<div class="tw:absolute tw:w-0 tw:h-full tw:opacity-60 tw:border-l" :style="{
left: left.string,
<div class="tw:absolute tw:w-0 tw:h-full tw:border-l" :style="{
left,
borderColor: marker.color,
}" />
}" :label="marker.name" />
</template>
<style scoped></style>

View File

@ -1,21 +1,32 @@
<!-- Thin colored vertical lines stretching across the timeline, below the clips -->
<script setup lang="ts">
import type { TimelineMarkerData } from "@/lib/Timeline";
import { useTimelineTicksBeats } from '@/lib/useTimelineTicks';
import { useTimelineStore } from "@/store/TimelineStore";
import MarkerLine from "./MarkerLine.vue";
import TickLine from "./TickLine.vue";
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>
<template>
<div class="tw:absolute tw:size-full">
<!-- timeline ticks for beats-->
<div class="tw:size-full">
<TickLine v-for="tick in ticks.ticks.value" :left="ticks.left(tick).value" />
<MarkerLine v-for="marker in timeline.markers" :marker />
</div>
<MarkerLine :marker />
<MarkerLine :marker="{ ...marker, name: '1', markerIn: 1 }" />
</div>
</template>
<style scoped></style>

View File

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

View File

@ -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 = [
"English",
"Russian",
@ -40,27 +33,27 @@ export interface AudioTrack {
Name: string;
IsExplicit: boolean;
Language: Language;
WindUpTimer: Seconds;
WindUpTimer: number;
Bpm: number;
Beats: Beats;
LoopOffset: Beats;
Beats: number;
LoopOffset: number;
Ext: string;
FileDurationIntro: Seconds;
FileDurationLoop: Seconds;
FileDurationIntro: number;
FileDurationLoop: number;
FileNameIntro: string;
FileNameLoop: string;
BeatsOffset: Beats;
BeatsOffset: number;
FadeOutBeat: Beats;
FadeOutDuration: Beats;
ColorTransitionIn: Beats;
ColorTransitionOut: Beats;
ColorTransitionEasing: EasingName;
FadeOutBeat: number;
FadeOutDuration: number;
ColorTransitionIn: number;
ColorTransitionOut: number;
ColorTransitionEasing: string;
FlickerLightsTimeSeries: Beats[];
FlickerLightsTimeSeries: number[];
Lyrics: TimeSeries<string>;
DrunknessLoopOffsetTimeSeries: TimeSeries<Beats>;
CondensationLoopOffsetTimeSeries: TimeSeries<Beats>;
DrunknessLoopOffsetTimeSeries: TimeSeries<number>;
CondensationLoopOffsetTimeSeries: TimeSeries<number>;
Palette: ColorString[];
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;
}
@ -125,10 +118,7 @@ export interface Codenames {
};
}
/**
* Format timeline wall clock timestamp like [-]0:00.000 with configurable milliseconds precision.
*/
export function formatTime(time: Seconds, precision: number = 3): string {
export function formatTime(time: number, precision: number = 3): string {
const isNegative = time < 0;
const isNegativeString = isNegative ? "-" : "";
if (isNegative) {
@ -136,85 +126,50 @@ export function formatTime(time: Seconds, precision: number = 3): string {
}
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
const milliseconds = Math.floor((time * 1000) % 1000);
const secondsString = seconds.toString().padStart(2, "0");
precision = clamp(precision, 0, 3);
if (precision === 0) {
return `${isNegativeString}${minutes}:${secondsString}`;
}
const factor = 10 ** precision;
const subsecond = Math.floor((time * factor) % factor);
const subsecondString = subsecond.toString().padStart(precision, "0");
return `${isNegativeString}${minutes}:${secondsString}.${subsecondString}`;
const millisecondsString = milliseconds.toString().padStart(precision, "0");
return `${isNegativeString}${minutes}:${secondsString}.${millisecondsString}`;
}
/**
* Format timeline beats timestamp like [-]00.000 with configurable precision for fractional part.
*/
export function formatBeats(beats: Beats, precision: number = 3): string {
export function formatBeats(beats: number, precision: number = 3): string {
const isNegative = beats < 0;
const isNegativeString = isNegative ? "-" : "";
if (isNegative) {
beats = -beats;
}
const integer = Math.floor(beats);
const integerString = integer.toString().padStart(2, "0");
precision = clamp(precision, 0, 3);
if (precision === 0) {
return `${isNegativeString}${integerString}`;
}
const factor = 10 ** precision;
const fractional = Math.floor((beats % 1) * factor);
const fractional = Math.floor((beats % 1) * 1000);
const integerString = integer.toString().padEnd(2, "0");
const fractionalString = fractional.toString().padStart(precision, "0");
return `${isNegativeString}${integerString}.${fractionalString}`;
}
export function secondsToBeats(track: AudioTrack, seconds: Seconds): Beats {
export function secondsToBeats(track: AudioTrack, seconds: number): number {
const percent = seconds / track.FileDurationLoop;
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;
return percent * track.FileDurationLoop;
}
/** Duration of LoopOffset beats converted to seconds. */
export function loopOffsetSeconds(track: AudioTrack): Seconds {
export function loopOffsetSeconds(track: AudioTrack): number {
return beatsToSeconds(track, track.LoopOffset);
}
/** Duration of Wind-up Timer plus Loop Offset combined and converted to seconds. */
export function introWithLoopOffsetDurationSeconds(track: AudioTrack): Seconds {
export function introWithLoopOffsetDurationSeconds(track: AudioTrack): number {
const { WindUpTimer } = track;
return WindUpTimer + loopOffsetSeconds(track);
}
/** Duration of Wind-up Timer plus Loop Offset plus one full loop combined and converted to seconds. */
export function totalDurationSeconds(track: AudioTrack): Seconds {
export function totalDurationSeconds(track: AudioTrack) {
const { FileDurationLoop } = track;
return introWithLoopOffsetDurationSeconds(track) + FileDurationLoop;
}
export function wrapTimeFn(track: AudioTrack): (time: Seconds) => Seconds {
const startOfLoop = introWithLoopOffsetDurationSeconds(track);
const endOfLoop = totalDurationSeconds(track);
return (time: Seconds): Seconds => {
return Math.max(0, modRange(time, startOfLoop, endOfLoop));
};
}
export function wrapTime(track: AudioTrack, time: Seconds): Seconds {
return wrapTimeFn(track)(time);
}
export function useWrapTime(
track: AudioTrack,
initialTime: Seconds,
): Ref<Seconds> {
const wrapper = wrapTimeFn(track);
return useSetterRef<Seconds>(initialTime, wrapper);
}

View File

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

View File

@ -3,12 +3,10 @@ import {
beatsToSeconds,
type ColorString,
loopOffsetSeconds,
secondsToBeats,
} from "@/lib/AudioTrack";
import { namedVars as clipColorNamedVars } from "@/lib/colors/clips";
import { namedVars as markerColorNamedVars } from "@/lib/colors/markers";
import * as namedColors from "@/lib/colors/named-vars";
import { green } from "./colors/named-vars";
import { iterWindowPairs } from "./iter";
import type { Beats } from "./units";
/**
* Reference point for all clips on the timeline track.
@ -28,7 +26,7 @@ export type ContentViewType =
| "palette"
| "text"
/** Interpolated line between points in time series. */
| "curve";
| "curve"
export interface MuzikaGromcheTimelineTracksMap {
intro: TimelineTrackData;
@ -60,56 +58,56 @@ export function emptyTimelineTracksMap(): MuzikaGromcheTimelineTracksMap {
return {
intro: {
name: "Intro",
color: clipColorNamedVars.lime,
color: namedColors.lime,
reference: "absolute",
clips: [],
contentViewType: "audio",
},
loop: {
name: "Loop",
color: clipColorNamedVars.blue,
color: namedColors.blue,
reference: "absolute",
clips: [],
contentViewType: "audio",
},
flickering: {
name: "Flickering",
color: clipColorNamedVars.violet,
color: namedColors.violet,
reference: "loop",
clips: [],
contentViewType: "event",
},
fadeOut: {
name: "Fade out",
color: clipColorNamedVars.chocolate,
color: namedColors.chocolate,
reference: "loop",
clips: [],
contentViewType: "fadeout",
},
palette: {
name: "Palette",
color: clipColorNamedVars.pink,
color: namedColors.pink,
reference: "wind-up",
clips: [],
contentViewType: "palette",
},
lyrics: {
name: "Lyrics",
color: clipColorNamedVars.tan,
color: namedColors.tan,
reference: "loop",
clips: [],
contentViewType: "text",
},
drunkness: {
name: "Drunkness",
color: clipColorNamedVars.orange,
color: namedColors.orange,
reference: "loop",
clips: [],
contentViewType: "curve",
},
condensation: {
name: "Condensation",
color: clipColorNamedVars.yellow,
color: namedColors.yellow,
reference: "loop",
clips: [],
contentViewType: "curve",
@ -122,31 +120,16 @@ export function generateClips(
): MuzikaGromcheTimelineTracksMap {
const tracks = emptyTimelineTracksMap();
if (!track.loadedIntro || !track.loadedLoop) return tracks;
tracks.intro.clips.push({
clipIn: 0,
duration: track.FileDurationIntro,
audioBuffer: track.loadedIntro,
});
tracks.intro.clips.push({ clipIn: 0, duration: track.FileDurationIntro });
{
let clipIn = track.FileDurationIntro;
tracks.loop.clips.push(
{
clipIn,
duration: track.FileDurationLoop,
audioBuffer: track.loadedLoop,
},
{ clipIn, duration: track.FileDurationLoop },
);
for (let i = 1; i < 10; i++) {
let clipIn2 = clipIn + track.FileDurationLoop * i;
tracks.loop.clips.push(
{
clipIn: clipIn2,
duration: track.FileDurationLoop,
autorepeat: true,
audioBuffer: track.loadedLoop,
},
{ clipIn: clipIn2, duration: track.FileDurationLoop, autorepeat: true },
);
}
}
@ -192,7 +175,7 @@ export interface TimelineTrackData {
color?: string;
reference: Reference;
clips: TimelineClipData[];
contentViewType: ContentViewType;
contentViewType?: ContentViewType,
}
export interface TimelineClipData {
@ -201,8 +184,6 @@ export interface TimelineClipData {
clipIn: number;
duration: number;
autorepeat?: boolean;
/** Represented audio buffer, for track.contentViewType === "audio" only */
audioBuffer?: AudioBuffer;
}
export function timelineClipAutorepeat(self: TimelineClipData): boolean {
@ -217,7 +198,7 @@ export function timelineClipColor(
track: TimelineTrackData,
clip: TimelineClipData,
): ColorString {
return clip.color ?? track.color ?? clipColorNamedVars.green;
return clip.color ?? track.color ?? green;
}
export function timelineClipLabel(
@ -232,88 +213,61 @@ export interface TimelineMarkerData {
color: string;
reference: Reference;
markerIn: number;
/**
* Originally intended to be a separation between beats and seconds references.
* But since most of the events are in beats coordinate space, let's repurpose it to be marker-defined.
*
* Defaults to "bottom".
*/
position?: "top" | "bottom";
}
export function generateMarkers(track: AudioTrack): TimelineMarkerData[] {
const markers: TimelineMarkerData[] = [];
const markers = [];
if (track.LoopOffset === 0) {
markers.push({
name: "Wind-up Timer & Loop Offset",
color: markerColorNamedVars.lavender,
color: namedColors.purple,
reference: "wind-up",
markerIn: 0,
position: "top",
});
} else {
markers.push({
name: "Wind-up Timer",
color: markerColorNamedVars.lavender,
color: namedColors.purple,
reference: "wind-up",
markerIn: 0,
position: "top",
});
markers.push({
name: "Loop Offset",
color: markerColorNamedVars.fuchsia,
color: namedColors.violet,
reference: "loop",
markerIn: 0,
position: "top",
});
}
markers.push({
name: "End of Loop",
color: markerColorNamedVars.purple,
color: namedColors.purple,
reference: "loop",
markerIn: track.Beats,
position: "top",
});
const reservedLoopOffsetBeats: Beats[] = [
-track.LoopOffset,
0,
track.Beats,
];
const firstBeat = Math.ceil(-secondsToBeats(track, track.WindUpTimer)) -
track.LoopOffset;
// TODO: i from absolute zero, not wind-up zero
for (let i = firstBeat; i < track.Beats; i++) {
if (reservedLoopOffsetBeats.includes(i)) {
continue;
}
for (let i = 1; i < track.Beats; i++) {
if (i % 4 === 0) {
// marker on strong beat
markers.push({
name: `Bar (${i})`,
color: markerColorNamedVars.blue,
name: "Bar",
color: namedColors.blue,
reference: "loop",
markerIn: i,
position: "bottom",
});
} else {
// regular marker on other beats
if (false) {
markers.push({
name: "Beat",
color: markerColorNamedVars.cyan,
color: namedColors.teal,
reference: "loop",
markerIn: i,
position: "bottom",
});
}
}
}
return markers;
return [];
}
export function toAbsoluteDuration(

View File

@ -0,0 +1,18 @@
export default [
"#eb6e01",
"#ffa833",
"#d4ad1f",
"#9fc615",
"#5f9921",
"#448f65",
"#019899",
"#005278",
"#4376a1",
"#9972a0",
"#d0568d",
"#e98cb5",
"#b9af97",
"#c4a07c",
"#996601",
"#8c5a3f",
] as const;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,19 +28,14 @@ function isEditableElement(el?: Element | null): boolean {
const tag = elm.tagName;
if (elm.isContentEditable) return true;
// Treat TEXTAREA as editable
if (tag === "TEXTAREA") {
const input = elm as HTMLTextAreaElement;
return !input.disabled && !input.readOnly;
}
if (tag === "TEXTAREA") return true;
// For INPUT, only consider text-like input types as editable. This
// excludes sliders, checkboxes, radio buttons, buttons, file inputs, etc.
if (tag === "INPUT") {
const input = elm as HTMLInputElement;
// If no type attribute is present it defaults to 'text'
const type = (input.type || "text").toLowerCase();
if (textLikeTypes.has(type)) {
return !input.disabled && !input.readOnly;
}
if (textLikeTypes.has(type)) return true;
}
// ARIA text-like roles
if (

View File

@ -5,11 +5,5 @@ export type AnyTime = Seconds | Beats;
export type Px = number;
export type PxString = `${Px}px`;
/**
* zoom raw: suitable for scaling, range 1 .. 7.25
*/
export type ZoomRaw = number;
/**
* zoom discrete: suitable for sliders, range 0 .. 100 or -20 .. 100
*/
export type ZoomDiscrete = number;

View File

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

View File

@ -1,6 +1,10 @@
import { formatTime } from "@/lib/AudioTrack";
import { rangeInclusive } from "@/lib/iter";
import {
type AnyTime,
type Beats,
type Pixels,
type Seconds,
useOptimalBeatTickInterval,
useOptimalTickInterval,
useTicksBounds,
@ -14,7 +18,6 @@ import {
type MaybeRefOrGetter,
toValue,
} from "vue";
import type { AnyTime, Beats, Px, Seconds } from "./units";
export interface Ticks<T extends AnyTime> {
tickIn: ComputedRef<T>;
@ -22,9 +25,9 @@ export interface Ticks<T extends AnyTime> {
interval: ComputedRef<T>;
/** An inclusive range from tickIn to tickOut, with `interval` step. */
ticks: ComputedRef<T[]>;
width: ComputedRef<Px>;
width: ComputedRef<Pixels>;
widthPx: ComputedRef<string>;
left: (tickIn: T) => ComputedRef<Px>;
left: (tickIn: T) => ComputedRef<Pixels>;
label: (tickIn: T) => ComputedRef<string>;
}
@ -32,8 +35,8 @@ export function useTimelineTicks<T extends AnyTime>(
viewportIn: MaybeRefOrGetter<T>,
viewportOut: MaybeRefOrGetter<T>,
interval: ComputedRef<T>,
intervalToPixels: (interval: T) => Px,
positionToPixels: (position: T) => Px,
intervalToPixels: (interval: T) => Pixels,
positionToPixels: (position: T) => Pixels,
positionToLabel: (position: T) => string,
): Ticks<T> {
const ticksBounds = useTicksBounds(interval, viewportIn, viewportOut);

View File

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

View File

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

View File

@ -1,13 +1,7 @@
import { describe, expect, test } from "vitest";
import { computed, nextTick, shallowRef } from "vue";
import type { UseZoomAxis } from ".";
import {
useZoom,
useZoomAxis,
useZoomAxisManager,
zoomDiscreteToRaw,
zoomRawToDiscrete,
} from ".";
import { useZoom, useZoomAxis, zoomDiscreteToRaw, zoomRawToDiscrete } from ".";
describe("zoom conversion", () => {
test("zoomRawToDiscrete", () => {
@ -92,18 +86,15 @@ describe("useZoom", () => {
describe("useZoomAxis", () => {
test("baseline", () => {
expect(useZoomAxis).toBeDefined();
const axis: UseZoomAxis = useZoomAxis({ raw: 1 });
expect(axis.zoom.discrete.value).toBeDefined();
expect(axis.min.discrete.value).toBeDefined();
expect(axis.max.discrete.value).toBeDefined();
expect(axis.default.discrete.value).toBeDefined();
expect(axis.stepSmall.discrete.value).toBeDefined();
expect(axis.isAtMin.value).toBeDefined();
expect(axis.isAtMax.value).toBeDefined();
expect(axis.isAtDefault.value).toBeDefined();
axis.reset();
axis.zoomIn();
axis.zoomOut();
const zoom: UseZoomAxis = useZoomAxis({ raw: 1 });
expect(zoom.zoom.discrete.value).toBeDefined();
expect(zoom.min.discrete.value).toBeDefined();
expect(zoom.max.discrete.value).toBeDefined();
expect(zoom.default.discrete.value).toBeDefined();
expect(zoom.stepSmall.discrete.value).toBeDefined();
zoom.reset();
zoom.zoomIn();
zoom.zoomOut();
useZoomAxis({
raw: 1,
@ -116,192 +107,105 @@ describe("useZoomAxis", () => {
});
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.
// If any of these assignments do NOT produce a TS error, the TypeScript
// compiler will fail due to the @ts-expect-error directive.
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
axis.min.raw.value = 32;
zoom.min.raw.value = 32;
// @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.
axis.default.raw.value = 2;
zoom.default.raw.value = 2;
// @ts-expect-error Cannot assign to 'value' because it is a read-only property.
axis.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);
zoom.stepSmall.raw.value = 2;
});
test("reset sets zoom to default", async () => {
const axis = useZoomAxis({
const z = useZoomAxis({
raw: 1,
default: 5,
});
// change zoom and ensure reset restores default
axis.zoom.discrete.value = 0;
z.zoom.discrete.value = 0;
await nextTick();
expect(axis.zoom.discrete.value).toBe(0);
expect(z.zoom.discrete.value).toBe(0);
axis.reset();
z.reset();
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 () => {
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();
expect(axis.zoom.raw.value).toBe(3);
expect(z.zoom.raw.value).toBe(3);
expect(rawRef.value).toBe(3);
});
test("zoomIn / zoomOut are callable (no runtime throw)", () => {
const axis = useZoomAxis({ raw: 1 });
const z = useZoomAxis({ raw: 1 });
expect(() => {
axis.zoomIn();
axis.zoomOut();
z.zoomIn();
z.zoomOut();
}).not.toThrow();
});
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
axis.zoom.discrete.value = 15;
z.zoom.discrete.value = 15;
await nextTick();
axis.zoomIn();
z.zoomIn();
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 () => {
const axis = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
axis.zoomOut();
const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
z.zoomOut();
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 () => {
const axis = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
axis.zoomIn();
const z = useZoomAxis({ raw: zoomDiscreteToRaw(15), stepBig: 10 });
z.zoomIn();
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 () => {
const axis = useZoomAxis({ raw: zoomDiscreteToRaw(20), stepBig: 10 });
const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), stepBig: 10 });
axis.zoomIn();
z.zoomIn();
await nextTick();
expect(axis.zoom.discrete.value).toBe(30);
expect(z.zoom.discrete.value).toBe(30);
axis.zoomOut();
z.zoomOut();
await nextTick();
expect(axis.zoom.discrete.value).toBe(20);
expect(z.zoom.discrete.value).toBe(20);
axis.zoomOut();
z.zoomOut();
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 () => {
const axis = useZoomAxis({
raw: zoomDiscreteToRaw(20),
max: 25,
stepBig: 10,
});
const z = useZoomAxis({ raw: zoomDiscreteToRaw(20), max: 25, stepBig: 10 });
axis.zoomIn();
z.zoomIn();
await nextTick();
// should clamp to max (25) instead of exceeding it
expect(axis.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);
expect(z.zoom.discrete.value).toBe(25);
});
});

View File

@ -1,7 +1,100 @@
import type { Px, ZoomDiscrete, ZoomRaw } from "@/lib/units";
import { clamp } from "@vueuse/core";
import type { ComputedRef, DeepReadonly, MaybeRef, Ref } from "vue";
import { computed, toRef, toValue } from "vue";
import {
computed,
type ComputedRef,
type DeepReadonly,
type MaybeRef,
type Ref,
shallowRef,
toRef,
toValue,
type WritableComputedRef,
} from "vue";
export function useZoomAxisOld(
{
contentSizeForZoom,
viewportScrollOffset,
viewportSize,
zoom,
zoomMin,
zoomMax,
}: {
contentSizeForZoom: (zoom?: ZoomRaw) => Px;
viewportScrollOffset: Ref<Px>;
viewportSize: Ref<Px>;
zoom: Ref<ZoomRaw>;
zoomMin: ZoomRaw;
zoomMax: ZoomRaw;
},
) {
const contentSize = computed<number>(() => contentSizeForZoom());
function contentSizeIncludingEmptySpaceForZoom(zoom?: ZoomRaw): Px {
return Math.max(contentSizeForZoom(zoom), viewportSize.value);
}
const contentSizeIncludingEmptySpace = computed<Px>(() =>
contentSizeIncludingEmptySpaceForZoom()
);
// When zooming, timeline should stay centered at current viewport center
const zoomWrapper = computed<ZoomRaw>({
get() {
return zoom.value;
},
set(value) {
// sanitize
value = clamp(value, zoomMin, zoomMax);
// calculate current and anticipated content size
const currentContentSize = contentSizeIncludingEmptySpaceForZoom();
const nextContentSize = contentSizeIncludingEmptySpaceForZoom(
value,
);
// calculate current offset of center
const halfViewportSize = viewportSize.value / 2;
const currentOffsetOfCenter = viewportScrollOffset.value +
halfViewportSize;
// keep the timeline centered around current viewport's center
const percent = currentOffsetOfCenter / currentContentSize;
const nextOffsetOfCenter = percent * nextContentSize;
let nextOffset = nextOffsetOfCenter - halfViewportSize;
const maxOffset = nextContentSize - viewportSize.value;
nextOffset = clamp(nextOffset, 0, maxOffset);
zoom.value = value;
window.requestAnimationFrame(() => {
viewportScrollOffset.value = nextOffset;
});
},
});
return {
contentSize,
contentSizeIncludingEmptySpaceForZoom,
contentSizeIncludingEmptySpace,
zoom: zoomWrapper,
};
}
export interface UseZoomAxisManagerOptions {
contentSizeForZoom: (zoom: ZoomRaw) => Px;
viewportScrollOffset: Ref<Px>;
viewportSize: Readonly<Ref<Px>>;
zoom: Ref<ZoomDiscrete>;
zoomMin: ZoomDiscrete;
zoomMax: ZoomDiscrete;
defaultZoom: ZoomDiscrete;
}
export interface UseZoomAxisManagerReturn {
contentSize: ComputedRef<Px>;
contentSizeIncludingEmptySpaceForZoom: (zoom?: ZoomRaw) => Px;
contentSizeIncludingEmptySpace: ComputedRef<Px>;
axis: UseZoomAxis;
}
const SCALE_INVERSE = 40;
const SCALE_BELOW_THESHOLD = 16;
@ -62,7 +155,8 @@ const DEFAULT_ZOOM_STEP_BIG_DISCRETE: ZoomDiscrete = 10;
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(
options: UseZoomOptions,
@ -104,35 +198,13 @@ export function useZoom(
export interface UseZoomAxisOptions {
raw: MaybeRef<ZoomRaw>;
/**
* Limit of zooming out (everything becomes small).
*
* Defaults to `DEFAULT_ZOOM_MIN_DISCRETE`.
*/
// limits
min?: ZoomDiscrete;
/**
* Limit of zooming in (everything becomes large).
*
* Defaults to `DEFAULT_ZOOM_MAX_DISCRETE`.
*/
max?: ZoomDiscrete;
/**
* Default zoom to reset to, e.g. when double clicking associated zoom slider.
*
* Defaults to `DEFAULT_ZOOM_DISCRETE`.
*/
default?: ZoomDiscrete;
/**
* Step size that can be used for granular controls like a range slider.
*
* Defaults to `DEFAULT_ZOOM_STEP_SMALL_DISCRETE`.
*/
// Can be used for granular controls like a range slider
stepSmall?: ZoomDiscrete;
/**
* Step sizee that can be used for buttons.
*
* Defaults to `DEFAULT_ZOOM_STEP_BIG_DISCRETE`.
*/
// Can be used for buttons
stepBig?: ZoomDiscrete;
}
@ -145,29 +217,10 @@ export interface UseZoomAxis {
default: DeepReadonly<UseZoom>;
stepSmall: DeepReadonly<UseZoom>;
isAtMin: Readonly<Ref<boolean>>;
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.
*/
// Can be triggered by double clicking on the control
reset: () => void;
/**
* 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.
*/
// 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;
/**
* 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;
}
@ -181,14 +234,11 @@ export function useZoomAxis(options: UseZoomAxisOptions): UseZoomAxis {
stepBig: stepBigDiscrete = DEFAULT_ZOOM_STEP_BIG_DISCRETE,
} = options;
const useZoomMinMax = (raw: MaybeRef<number>): UseZoom => {
return useZoom({ raw, min: minDiscrete, max: maxDiscrete });
};
const zoom = useZoomMinMax(raw);
const min = useZoomMinMax(zoomDiscreteToRaw(minDiscrete));
const max = useZoomMinMax(zoomDiscreteToRaw(maxDiscrete));
const default_ = useZoomMinMax(zoomDiscreteToRaw(defaultDiscrete));
const stepSmall = useZoomMinMax(zoomDiscreteToRaw(stepSmallDiscrete));
const zoom = useZoom({ raw, min: minDiscrete, max: maxDiscrete });
const min = useZoom({ raw: zoomDiscreteToRaw(minDiscrete) });
const max = useZoom({ raw: zoomDiscreteToRaw(maxDiscrete) });
const default_ = useZoom({ raw: zoomDiscreteToRaw(defaultDiscrete) });
const stepSmall = useZoom({ raw: zoomDiscreteToRaw(stepSmallDiscrete) });
function reset() {
zoom.discrete.value = defaultDiscrete;
@ -218,100 +268,65 @@ export function useZoomAxis(options: UseZoomAxisOptions): UseZoomAxis {
default: default_,
stepSmall,
isAtMin: computed(() => zoom.discrete.value <= minDiscrete),
isAtMax: computed(() => zoom.discrete.value >= maxDiscrete),
isAtDefault: computed(() => zoom.discrete.value === defaultDiscrete),
reset,
zoomIn,
zoomOut,
};
}
export interface UseZoomAxisManagerOptions {
contentSizeForZoom: (zoom: ZoomRaw) => Px;
viewportScrollOffset: Ref<Px>;
viewportSize: Readonly<Ref<Px>>;
zoomOptions: UseZoomAxisOptions;
}
// export function useZoomAxisManager(
// {
// contentSizeForZoom,
// viewportScrollOffset,
// viewportSize,
// zoom,
// zoomMin,
// zoomMax,
// }: UseZoomAxisManagerOptions,
// ): UseZoomAxisManagerReturn {
// const contentSize = computed<Px>(() => contentSizeForZoom());
export interface UseZoomAxisManagerReturn {
contentSize: ComputedRef<Px>;
// contentSizeIncludingEmptySpaceForZoom: (zoom?: ZoomRaw) => Px;
contentSizeIncludingEmptySpace: ComputedRef<Px>;
axis: UseZoomAxis;
}
// function contentSizeIncludingEmptySpaceForZoom(zoom?: number): number {
// return Math.max(contentSizeForZoom(zoom), viewportSize.value);
// }
// const contentSizeIncludingEmptySpace = computed<number>(() =>
// contentSizeIncludingEmptySpaceForZoom()
// );
/**
* Harness a bunch of functionality related to zooming along a single axis.
*/
export function useZoomAxisManager(
options: UseZoomAxisManagerOptions,
): UseZoomAxisManagerReturn {
const {
contentSizeForZoom,
viewportScrollOffset,
viewportSize,
zoomOptions,
} = options;
// // When zooming, timeline should stay centered at current viewport center
// const zoomWrapper = computed<number>({
// get() {
// return zoom.value;
// },
// set(value) {
// // sanitize
// value = clamp(value, zoomMin, zoomMax);
// // calculate current and anticipated content size
// const currentContentSize = contentSizeIncludingEmptySpaceForZoom();
// const nextContentSize = contentSizeIncludingEmptySpaceForZoom(
// value,
// );
// // calculate current offset of center
// const halfViewportSize = viewportSize.value / 2;
// const currentOffsetOfCenter = viewportScrollOffset.value +
// halfViewportSize;
const contentSize = computed<Px>(() =>
contentSizeForZoom(toValue(zoomOptions.raw))
);
// // 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);
const contentSizeIncludingEmptySpaceForZoom = (zoom: number): number =>
Math.max(contentSizeForZoom(zoom), viewportSize.value);
// zoom.value = value;
// viewportScrollOffset.value = nextOffset;
// },
// });
const contentSizeIncludingEmptySpace = computed<number>(() =>
contentSizeIncludingEmptySpaceForZoom(toValue(zoomOptions.raw))
);
const useZoomWithAutomaticViewportOffset = (
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,
// return {
// contentSize,
// contentSizeIncludingEmptySpaceForZoom,
contentSizeIncludingEmptySpace,
axis,
};
}
// contentSizeIncludingEmptySpace,
// zoom: zoomWrapper,
// };
// }

View File

@ -1,9 +1,7 @@
import { tryOnScopeDispose } from "@vueuse/core";
import {
computed,
type MaybeRefOrGetter,
type Ref,
shallowRef,
toValue,
watch,
type WatchHandle,
@ -20,13 +18,11 @@ export function toPx(value: MaybeRefOrGetter<number>) {
function multiWatchHandle(...handles: WatchHandle[]): WatchHandle {
const watchHandle = () => {
handles.forEach((h) => h.stop());
};
}
watchHandle.pause = () => handles.forEach((h) => h.pause());
watchHandle.resume = () => handles.forEach((h) => h.resume());
watchHandle.stop = watchHandle;
tryOnScopeDispose(watchHandle);
return watchHandle;
}
@ -49,37 +45,3 @@ export function bindTwoWay<T>(ref1: Ref<T>, ref2: Ref<T>): WatchHandle {
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;
},
});
}

View File

@ -1,18 +1,16 @@
<script setup lang="ts">
import ErrorScreen from '@/components/ErrorScreen.vue';
import LoadingScreen from '@/components/LoadingScreen.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 onInputKeyStroke from '@/lib/onInputKeyStroke';
import type { UseZoomAxis } from '@/lib/useZoomAxis';
import { useScrollStore } from '@/store/ScrollStore';
import { useTimelineStore } from '@/store/TimelineStore';
import { useTrackStore } from '@/store/TrackStore';
import { useEventListener } from '@vueuse/core';
import { useEventListener, useRafFn } from '@vueuse/core';
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 ErrorScreen from '@/components/ErrorScreen.vue';
const scrollContainer = useTemplateRef('scrollContainer');
const scrollStore = useScrollStore();
@ -27,42 +25,45 @@ watch(() => String(route.params.trackName), fetchTrack, { immediate: true })
async function fetchTrack(trackName: string) {
await trackStore.fill();
await trackStore.setCurrentAudioTrackByName(trackName);
const audioTrack = trackStore.currentAudioTrack;
timeline.setAudioTrack(audioTrack);
timeline.setAudioTrack(trackStore.currentAudioTrack);
}
const { currentAudioTrack, currentAudioTrackName, audioTrackStatus, audioTrackProgress, audioTrackError } = storeToRefs(trackStore);
onInputKeyStroke((event) => (event.key === 'k'), (event) => {
const player = timeline.player;
player?.stop({ rememberPosition: true });
event.preventDefault();
});
const fpsLimit = 60
const { pause, resume } = useRafFn(({ delta }) => {
if (currentAudioTrack.value && trackStore.isPlaying) {
const deltaSeconds = delta / 1000;
timeline.advance(deltaSeconds);
}
}, { immediate: false, fpsLimit });
onInputKeyStroke((event) => event.key === ' ', (event) => {
if (!event.repeat) {
const player = timeline.player;
if (player) {
const rememberPosition = event.shiftKey;
if (player.playback.isPlaying.value) {
player.stop({ rememberPosition });
watch(() => trackStore.isPlaying, (isPlaying: boolean) => {
if (isPlaying) {
resume();
} else {
player.play();
}
pause();
}
}, { immediate: true });
useEventListener(document, 'keydown', (event) => {
if (event.key === ' ') {
if (!event.repeat) {
trackStore.togglePlayPause();
}
// Prevent auto-repeated spacebar from triggering buttons
event.preventDefault();
}
});
onBeforeRouteLeave((_to, _from, next) => {
timeline.player?.stop();
trackStore.stop();
trackStore.setCurrentAudioTrackByName("");
next();
});
onBeforeRouteUpdate((to, from, next) => {
if (to.params.trackName !== from.params.trackName) {
timeline.player?.stop();
trackStore.pause();
}
next();
});
@ -73,10 +74,6 @@ const errorTitle = computed(() => audioTrackStatus.value === 'error'
: "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>
<template>
@ -87,16 +84,13 @@ const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
<ErrorScreen v-if="audioTrackStatus === 'error'" :title="errorTitle" :description="audioTrackError" />
<div v-if="audioTrackStatus === 'ready'" class="tw:h-full tw:isolate">
<div class="tw:h-full tw:max-h-full tw:flex tw:flex-col">
<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 tw:bg-(--player-background-color)">
<!-- <TrackInfo v-if="false && currentAudioTrack" :track="currentAudioTrack" :edit="false" /> -->
<div class="tw:flex-1">
<TrackInfo v-if="false && currentAudioTrack" :track="currentAudioTrack" :edit="false" />
<!-- TODO: debug data -->
<div class="tw:flex tw:flex-col tw:items-center tw:p-8 tw:text-sm">
<h1 class="tw:text-2xl tw:pb-4">🚧 Section Under Construction! 🚧</h1>
<p>Viewport size:
{{ timeline.viewportWidth.toFixed(2) }} x {{ timeline.viewportHeight.toFixed(2) }}
</p>
@ -106,28 +100,19 @@ const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
<p>Duration: {{ timeline.duration }}</p>
<p>Viewport duration: {{ timeline.viewportDurationSeconds.toFixed(3) }}</p>
<hr/>
<p>Zoom: {{ viewportZoomHorizontal.zoom.raw.value.toFixed(4) }} x {{
viewportZoomVertical.zoom.raw.value.toFixed(4) }}
</p>
<p>Content size: {{ timeline.contentWidth }} x ({{ timeline.contentHeight }} = {{ timeline.trackHeight
}}px x {{ timeline.visibleTracks.length }} tracks)</p>
<p>Zoom: {{ timeline.viewportZoomHorizontal.toFixed(4) }} x {{ timeline.viewportZoomVertical.toFixed(4) }}</p>
<p>Content size: {{ timeline.contentWidth }} x ({{ timeline.contentHeight }} = {{ timeline.trackHeight }}px x {{ timeline.visibleTracks.length }} tracks)</p>
<p>including empty space: {{ timeline.contentWidthIncludingEmptySpace.toFixed(2) }}</p>
<hr/>
<p>Viewport In/Out ...Seconds: {{ timeline.viewportInSeconds.toFixed(3) }} .. {{
timeline.viewportOutSeconds.toFixed(3) }}</p>
<p>... Loop Offset Beats: {{ timeline.viewportInLoopOffsetBeats.toFixed(3) }} .. {{
timeline.viewportOutLoopOffsetBeats.toFixed(3) }}</p>
<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>
<p>playheadPosition: {{ timeline.playheadPosition.toFixed(3) }}</p>
</div>
<PreviewScnene v-if="false" />
</div>
<InspectorPanel />
</div>
<TimelinePanel class="tw:flex-1 tw:min-h-50" />
</div>
</div>

View File

@ -1,7 +1,3 @@
import audioEngine, {
type PlayerControls,
useLivePlaybackPosition,
} from "@/audio/AudioEngine";
import {
type AudioTrack,
beatsToSeconds,
@ -9,6 +5,7 @@ import {
secondsToBeats,
totalDurationSeconds,
} from "@/lib/AudioTrack";
import { modRange } from "@/lib/math";
import {
emptyTimelineTracksMap,
generateClips,
@ -18,43 +15,26 @@ import {
timelineTracksArray,
} from "@/lib/Timeline";
import type { Beats, Px, Seconds } from "@/lib/units";
import type { UseZoomAxis } from "@/lib/useZoomAxis";
import { useZoomAxisManager, zoomRawToDiscrete } from "@/lib/useZoomAxis";
import { useZoomAxisOld } from "@/lib/useZoomAxis";
import { toPx } from "@/lib/vue";
import { clamp, useLocalStorage } from "@vueuse/core";
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_RAW_VERTICAL = 3.0;
export const DEFAULT_ZOOM_HORIZONTAL = 1.0;
export const DEFAULT_ZOOM_VERTICAL = 3.0;
const DEFAULT_HEADER_HEIGHT = 34; // px
// TODO: on mobile default to 100px or even less
const DEFAULT_SIDEBAR_WIDTH = 140; // px
const DEFAULT_TRACK_HEIGHT = 72 / DEFAULT_ZOOM_RAW_VERTICAL; // px
const DEFAULT_TRACK_HEIGHT = 72 / DEFAULT_ZOOM_VERTICAL; // px
const EXTRA_DURATION_AT_END_SECONDS = 0;
export const useTimelineStore = defineStore("timeline", {
state: () => {
// actual content
const _audioTrack = shallowRef<AudioTrack | null>(null);
const 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 audioTrack = shallowRef<AudioTrack | null>(null);
const tracksMap = shallowRef(emptyTimelineTracksMap());
const markers = [] as TimelineMarkerData[];
@ -70,16 +50,19 @@ export const useTimelineStore = defineStore("timeline", {
return _viewportScrollOffsetLeft.value;
},
set(value) {
const max = contentWidthIncludingEmptySpace.value - viewportWidth.value;
_viewportScrollOffsetLeft.value = clamp(value, 0, max);
_viewportScrollOffsetLeft.value = clamp(
value,
0,
contentWidthIncludingEmptySpace.value - viewportWidth.value,
);
},
});
// horizontal zoom 1 equals to full timeline duration
const _viewportZoomHorizontal = shallowRef(DEFAULT_ZOOM_RAW_HORIZONTAL);
const _viewportZoomHorizontal = shallowRef(DEFAULT_ZOOM_HORIZONTAL);
const _viewportZoomVertical = useLocalStorage(
"timeline.viewportZoomVertical",
DEFAULT_ZOOM_RAW_VERTICAL,
DEFAULT_ZOOM_VERTICAL,
);
function trackHeightForZoom(zoom: number = _viewportZoomVertical.value) {
@ -102,62 +85,33 @@ export const useTimelineStore = defineStore("timeline", {
return trackHeight * visibleTracks.value.length;
}
// TODO: zoom around playhead
const {
contentSize: contentWidth,
// contentSizeIncludingEmptySpaceForZoom: contentWidthIncludingEmptySpaceForZoom,
contentSizeIncludingEmptySpace: contentWidthIncludingEmptySpace,
axis: viewportZoomHorizontal,
} = useZoomAxisManager({
zoom: viewportZoomHorizontal,
} = useZoomAxisOld({
contentSizeForZoom: contentWidthForZoom,
viewportScrollOffset: viewportScrollOffsetLeft,
viewportSize: viewportWidth,
zoomOptions: {
raw: _viewportZoomHorizontal,
min: -20,
max: 200,
default: zoomRawToDiscrete(DEFAULT_ZOOM_RAW_HORIZONTAL),
stepSmall: 1,
stepBig: 10,
},
zoom: _viewportZoomHorizontal,
zoomMin: 0.5,
zoomMax: 19.75,
});
markRaw(viewportZoomHorizontal);
const {
contentSize: contentHeight,
// contentSizeIncludingEmptySpaceForZoom: contentHeightIncludingEmptySpaceForZoom,
contentSizeIncludingEmptySpace: contentHeightIncludingEmptySpace,
axis: viewportZoomVertical,
} = useZoomAxisManager({
zoom: viewportZoomVertical,
} = useZoomAxisOld({
contentSizeForZoom: contentHeightForZoom,
viewportScrollOffset: viewportScrollOffsetTop,
viewportSize: viewportHeight,
zoomOptions: {
raw: _viewportZoomVertical,
min: 0,
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();
zoom: _viewportZoomVertical,
zoomMin: 1,
zoomMax: 7.25,
});
return ({
@ -190,18 +144,15 @@ export const useTimelineStore = defineStore("timeline", {
viewportScrollOffsetTop,
viewportScrollOffsetLeft,
/* viewport zoom axes, managed by zoom sliders. */
/* viewport zoom, managed by zoom sliders. */
// horizontal zoom 1 equals to full timeline duration
viewportZoomHorizontal,
viewportZoomVertical,
// playhead and scrubbing / preview positions in absolute seconds
player,
playheadPosition: livePlaybackPosition,
isPlaying: computed<boolean>(() =>
player.value?.playback.isPlaying.value ?? false
),
playheadPosition: 0,
scrubbingPosition: NaN,
// auxilary elements
headerHeight: DEFAULT_HEADER_HEIGHT,
@ -216,9 +167,8 @@ export const useTimelineStore = defineStore("timeline", {
return toPx(this.contentHeightIncludingEmptySpace);
},
durationIncludingEmptySpace(): number {
const axis = this.viewportZoomHorizontal as any as UseZoomAxis;
return axis.zoom.raw.value < 1
? this.duration / axis.zoom.raw.value
return this.viewportZoomHorizontal < 1
? this.duration / this.viewportZoomHorizontal
: this.duration;
},
durationBeatsIncludingEmptySpace(): number {
@ -282,14 +232,20 @@ export const useTimelineStore = defineStore("timeline", {
}
return secondsToBeats(this.audioTrack, this.duration);
},
playheadPositionLoopOffsetBeats(): Beats {
playheadPositionBeats(): Beats {
if (!this.audioTrack) {
return 0;
}
const loopOffsetSeconds = introWithLoopOffsetDurationSeconds(this.audioTrack);
const playheadLoopOffsetSeconds = this.playheadPosition - loopOffsetSeconds;
const playheadLoopOffsetBeats = secondsToBeats(this.audioTrack, playheadLoopOffsetSeconds);
return playheadLoopOffsetBeats;
return secondsToBeats(this.audioTrack, this.playheadPosition);
},
scrubbingPositionBeats(): Beats {
if (!this.audioTrack) {
return 0;
}
if (Number.isNaN(this.scrubbingPosition)) {
return NaN;
}
return secondsToBeats(this.audioTrack, this.scrubbingPosition);
},
/* Measurements and convertions */
pixelsToSeconds(_state) {
@ -356,11 +312,13 @@ export const useTimelineStore = defineStore("timeline", {
this.audioTrack = null;
this.duration = 0;
this.resetViewport();
this.playheadPosition = 0;
this.scrubbingPosition = NaN;
this.tracksMap = emptyTimelineTracksMap();
this.markers = [];
},
resetViewport() {
this.viewportZoomHorizontal.reset();
this.viewportZoomHorizontal = DEFAULT_ZOOM_HORIZONTAL;
// Keep it in local storage.
// this.viewportZoomVertical = DEFAULT_ZOOM_VERTICAL;
},
@ -385,8 +343,7 @@ export const useTimelineStore = defineStore("timeline", {
const duration = out_ - in_;
const totalDuration = totalDurationSeconds(audioTrack);
const zoom = totalDuration / duration;
const axis = this.viewportZoomHorizontal as any as UseZoomAxis;
axis.zoom.raw.value = zoom;
this.viewportZoomHorizontal = zoom;
// let the viewport adjust and propagate size changes.
window.requestAnimationFrame(() => {
const left = this.secondsToPixels(in_);
@ -394,9 +351,8 @@ export const useTimelineStore = defineStore("timeline", {
});
},
zoomToggleBetweenWholeAndLoop() {
const axis = this.viewportZoomHorizontal as any as UseZoomAxis;
if (axis.zoom.raw.value !== 1) {
axis.zoom.raw.value = 1;
if (this.viewportZoomHorizontal !== 1) {
this.viewportZoomHorizontal = 1;
} else {
this.zoomToLoop();
}
@ -410,10 +366,25 @@ export const useTimelineStore = defineStore("timeline", {
this.duration = totalDurationSeconds(track) +
EXTRA_DURATION_AT_END_SECONDS;
this.resetViewport();
this.playheadPosition = 0;
this.scrubbingPosition = NaN;
// regenerate tracks content
this.tracksMap = generateClips(track);
this.markers = generateMarkers(track);
},
/** Update playback position */
advance(deltaSeconds: number) {
const { audioTrack } = this;
if (!audioTrack) {
return;
}
const startOfLoop = introWithLoopOffsetDurationSeconds(audioTrack);
const endOfLoop = totalDurationSeconds(audioTrack);
let position = this.playheadPosition + deltaSeconds;
position = modRange(position, startOfLoop, endOfLoop);
this.playheadPosition = position;
this.ensurePlayheadWithinViewport();
},
ensurePlayheadWithinViewport() {
if (
this.playheadPosition < this.viewportInSeconds ||
@ -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 });
},
},
});

View File

@ -1,11 +1,11 @@
import audioEngine, { VOLUME_MAX } from "@/audio/AudioEngine";
import type { AudioTrack, Codenames, Language } from "@/lib/AudioTrack";
import { sleep } from "@/lib/sleep";
import { introWithLoopOffsetDurationSeconds, totalDurationSeconds, type AudioTrack, type Codenames, type Language } from "@/lib/AudioTrack";
import { useStorage } from "@vueuse/core";
import { defineStore } from "pinia";
import { shallowRef } from "vue";
import codenamesJsonUrl from "/MuzikaGromcheCodenames.json?url";
import tracksJsonUrl from "/MuzikaGromcheTracks.json?url";
import { sleep } from "@/lib/sleep";
// Don't mark it as unused, it is needed for debugging
sleep(0);
@ -38,6 +38,12 @@ export const useTrackStore = defineStore("track", {
muted: useStorage("player-volume-muted", false),
// persisted volume 0..1
volume: useStorage("player-volume", 1),
// audio engine manages AudioContext and nodes; store keeps serializable state only
isPlaying: false,
// Playback time elapsed since start of intro until the audio was paused.
// This is needed because Audio Nodes can not be resumed.
playedDuration: 0,
});
},
getters: {
@ -61,6 +67,17 @@ export const useTrackStore = defineStore("track", {
null;
};
},
// TODO: replace with TimelineStore
timelineTotalDurationSeconds(state) {
return (track: AudioTrack | null = state.currentAudioTrack) => {
return track ? totalDurationSeconds(track) : 0;
};
},
trackStartOfLoopSeconds(state) {
return (track: AudioTrack | null = state.currentAudioTrack) => {
return track ? introWithLoopOffsetDurationSeconds(track) : 0;
};
},
},
actions: {
async fill(signal?: AbortSignal) {
@ -125,6 +142,8 @@ export const useTrackStore = defineStore("track", {
},
async setCurrentAudioTrackByName(trackName: string, signal?: AbortSignal) {
this.pause();
this.rewindToIntro();
this.currentAudioTrackName = trackName;
const track = this.findTrackNamed(trackName);
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)
async fetchAudioBuffer(
url: string,

View File

@ -1,14 +1,6 @@
@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 "./reset.css";
@layer base {
* {
--main-background-color: #28282e;
--inactive-text-color: #909090;
@ -33,15 +25,9 @@
--input-background-color: #1f1f1f;
--input-outline-color: #070707;
/* disabled */
--input-disabled-text-color: #525256;
--input-disabled-background-color: #242428;
--input-disabled-outline-color: #1a1a1d;
/* selected */
--input-selected-outline-color: #e64b3d;
--input-outline-selected-color: #e64b3d;
--input-border-width: 1px;
--input-border-radius: 4px;
--input-selection-color: #4b4b4b;
--timeline-background-color: var(--main-background-color);
--timeline-background-top-color: #18181e;
@ -70,31 +56,9 @@
--timeline-bar-width: 1px;
--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;
/* See ./lib/colors/markers.ts */
--timeline-marker-color-blue: #007fe3;
--timeline-marker-color-cyan: #00ced0;
--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;
--timeline-clip-border-radius: 4px;
@ -109,7 +73,6 @@
--timeline-clip-outline-selected-width: 2px;
--timeline-clip-outline-selected: var(--timeline-clip-outline-selected-width) solid var(--timeline-clip-outline-selected-color);
/* See ./lib/colors/clips.ts */
--timeline-clip-color-orange: #eb6e01;
--timeline-clip-color-apricot: #ffa833;
--timeline-clip-color-yellow: #d4ad1f;
@ -131,21 +94,6 @@
--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 {
@ -182,12 +130,6 @@
#app {
height: 100%;
}
}
@theme {
--font-3270: "3270", monospace;
--font-mono: "3270", monospace;
}
@layer utilities {
.toolbar-background {
@ -201,10 +143,6 @@
.scrollbar-none {
scrollbar-width: none;
}
.toolbar-icon-shadow {
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
}
}
@layer components {
@ -213,150 +151,3 @@
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);
}
}
}

View File

@ -3,9 +3,7 @@ import { render } from "vitest-browser-vue";
import SearchField from "@/components/SearchField.vue";
test("default placeholder", async () => {
const { getByRole } = render(SearchField, {
props: { modelValue: "" },
});
const { getByRole } = render(SearchField);
const searchBox = getByRole("searchbox");
await expect.element(searchBox).toBeInTheDocument();

View File

@ -1,18 +1,18 @@
/// <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 vue from "@vitejs/plugin-vue";
import vueDevTools from "vite-plugin-vue-devtools";
import svgLoader from "vite-svg-loader";
import tailwindcss from "@tailwindcss/vite";
import { playwright } from "@vitest/browser-playwright";
import { resolve } from "node:path";
// https://vite.dev/config/
export default defineConfig({
server: {
host: "0.0.0.0",
},
base: "/muzika-gromche",
plugins: [
vue(),
vueDevTools(),
@ -39,9 +39,29 @@ export default defineConfig({
"@": resolve(__dirname, "./src/"),
},
},
base: "/muzika-gromche",
css: {
modules: {
localsConvention: "camelCaseOnly",
},
},
test: {
globals: true,
// environment: 'jsdom',
// include tests in `tests/` directory
include: [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"tests/**/*.spec.ts",
"tests/**/*.test.ts",
],
browser: {
enabled: true,
provider: playwright(),
// https://vitest.dev/config/browser/playwright
instances: [
{ browser: "firefox" },
],
},
},
});

View File

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

View File

@ -992,8 +992,8 @@ namespace MuzikaGromche
public readonly record struct Easing(string Name, Func<float, float> Eval)
{
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 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 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));