Compare commits
1 Commits
81870ecd47
...
aa489eb305
| Author | SHA1 | Date |
|---|---|---|
|
|
aa489eb305 |
|
|
@ -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/`.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
|
@ -1,50 +0,0 @@
|
|||
import sharp from 'sharp';
|
||||
import toIco from 'png-to-ico';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const sourceIcon = path.resolve(__dirname, '../../icon.png');
|
||||
const outputDir = path.resolve(__dirname, '../public');
|
||||
|
||||
async function generateIcons() {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Generate PNGs
|
||||
const sizes = [32, 192, 256];
|
||||
for (const size of sizes) {
|
||||
const outputPath = path.join(outputDir, `icon-${size}.png`);
|
||||
await sharp(sourceIcon)
|
||||
.resize(size, size)
|
||||
.toFile(outputPath);
|
||||
console.log(`Generated ${outputPath}`);
|
||||
}
|
||||
|
||||
// Generate apple-touch-icon
|
||||
const appleIconPath = path.join(outputDir, 'apple-touch-icon.png');
|
||||
await sharp(sourceIcon)
|
||||
.resize(180, 180)
|
||||
.toFile(appleIconPath);
|
||||
console.log(`Generated ${appleIconPath}`);
|
||||
|
||||
// Generate favicon.ico
|
||||
const icoSizes = [16, 24, 32, 48];
|
||||
const buffers = await Promise.all(icoSizes.map(size =>
|
||||
sharp(sourceIcon)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toBuffer()
|
||||
));
|
||||
const icoBuffer = await toIco(buffers);
|
||||
const icoPath = path.join(outputDir, 'favicon.ico');
|
||||
await fs.writeFile(icoPath, icoBuffer);
|
||||
console.log(`Generated ${icoPath}`);
|
||||
}
|
||||
|
||||
generateIcons().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -9,470 +9,66 @@
|
|||
- exposes getPosition() to read current playback time relative to intro start
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
import type { Px } from "@/lib/units";
|
||||
import { useWeakCache } from "@/lib/useWeakCache";
|
||||
import { type Fn, tryOnScopeDispose, watchImmediate } from "@vueuse/core";
|
||||
import type { MaybeRefOrGetter, Ref } from "vue";
|
||||
import { computed, shallowRef, toValue, triggerRef } from "vue";
|
||||
|
||||
// Result of async computation
|
||||
interface UseWaveform {
|
||||
readonly isDone: Readonly<Ref<boolean>>;
|
||||
readonly peaks: Readonly<Ref<Float32Array>>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
interface WaveformComputation {
|
||||
readonly isDone: Readonly<Ref<boolean>>;
|
||||
readonly peaks: Readonly<Ref<Float32Array>>;
|
||||
/** Start or continue asynchronous computation. */
|
||||
run: () => void;
|
||||
/** Stops any ongoing asynchronous computation. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
const waveformsCache = useWeakCache<AudioBuffer, Map<Px, WaveformComputation>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const WAVEFORM_MIN_WIDTH = 10;
|
||||
|
||||
const emptyComputation: WaveformComputation = {
|
||||
isDone: shallowRef(false),
|
||||
peaks: shallowRef(new Float32Array(0)),
|
||||
run() {},
|
||||
stop() {},
|
||||
};
|
||||
|
||||
export function useWaveform(
|
||||
buffer: MaybeRefOrGetter<AudioBuffer>,
|
||||
width: MaybeRefOrGetter<Px>,
|
||||
): UseWaveform {
|
||||
const cleanups: Fn[] = [];
|
||||
const cleanup = () => {
|
||||
cleanups.forEach((fn) => fn());
|
||||
cleanups.length = 0;
|
||||
};
|
||||
|
||||
const compRef: Ref<WaveformComputation> = shallowRef(emptyComputation);
|
||||
|
||||
const stopWatch = watchImmediate(
|
||||
() =>
|
||||
[
|
||||
toValue(buffer),
|
||||
toValue(width),
|
||||
] as const,
|
||||
([b, w]) => {
|
||||
cleanup();
|
||||
|
||||
const map = waveformsCache.getOrNew(b);
|
||||
|
||||
if (w < WAVEFORM_MIN_WIDTH) {
|
||||
compRef.value = emptyComputation;
|
||||
return;
|
||||
}
|
||||
|
||||
let comp = map.get(w);
|
||||
if (!comp) {
|
||||
comp = useWaveformComputation(b, w);
|
||||
map.set(w, comp);
|
||||
}
|
||||
compRef.value = comp;
|
||||
comp.run();
|
||||
cleanups.push(() => {
|
||||
compRef.value = emptyComputation;
|
||||
comp.stop();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const stop = () => {
|
||||
stopWatch();
|
||||
cleanup();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return {
|
||||
isDone: computed(() => compRef.value.isDone.value),
|
||||
peaks: computed(() => compRef.value.peaks.value),
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
const useWaveformComputation = (
|
||||
buffer: AudioBuffer,
|
||||
width: Px,
|
||||
): WaveformComputation => {
|
||||
// How many times run() has been called without stop().
|
||||
// This whole computation should not stop until there is at least one user out there.
|
||||
let users = 0;
|
||||
|
||||
// How many pixels of `width` have been processed so far
|
||||
let progress = 0;
|
||||
|
||||
// Waveform data, length shall be equal to the requested width
|
||||
const waveform = new Float32Array(width);
|
||||
|
||||
const isDone = shallowRef(false);
|
||||
const peaks = shallowRef(waveform);
|
||||
|
||||
const nChannels = buffer.numberOfChannels;
|
||||
|
||||
const samplesPerPx = buffer.length / width;
|
||||
const blocksPerChannel: Float32Array<ArrayBuffer>[] = [];
|
||||
for (let channel = 0; channel < nChannels; channel++) {
|
||||
blocksPerChannel[channel] = new Float32Array(Math.ceil(samplesPerPx));
|
||||
}
|
||||
|
||||
const areWeDoneYet = () => progress >= width;
|
||||
|
||||
function stepBlock() {
|
||||
const blockStart = Math.floor(progress * samplesPerPx);
|
||||
const blockEnd = Math.floor((progress + 1) * samplesPerPx);
|
||||
const blockSize = blockEnd - blockStart;
|
||||
|
||||
for (let channel = 0; channel < nChannels; channel++) {
|
||||
buffer.copyFromChannel(blocksPerChannel[channel]!, channel, blockStart);
|
||||
}
|
||||
|
||||
waveform[progress] = compressBlock(blocksPerChannel, blockSize);
|
||||
progress += 1;
|
||||
}
|
||||
|
||||
function stepBatchOfBlocks() {
|
||||
// run blocks for up to ~10ms to keep UI responsive
|
||||
const start = performance.now();
|
||||
const progressStart = progress;
|
||||
while (!areWeDoneYet()) {
|
||||
stepBlock();
|
||||
if (performance.now() - start >= 10 || progress - progressStart > 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
triggerRef(peaks);
|
||||
// triggerRef may as well not trigger refs
|
||||
// https://github.com/vuejs/core/issues/9579
|
||||
// Combined with a throttled drawing function,
|
||||
// this is a slightly better-than-worse workaround.
|
||||
peaks.value = new Float32Array(0);
|
||||
peaks.value = waveform;
|
||||
|
||||
if (areWeDoneYet()) {
|
||||
isDone.value = true;
|
||||
timeoutID = NaN;
|
||||
} else {
|
||||
timeoutID = setTimeout(stepBatchOfBlocks, 1);
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutID: number = NaN;
|
||||
|
||||
return {
|
||||
isDone,
|
||||
peaks,
|
||||
run() {
|
||||
users += 1;
|
||||
|
||||
if (Number.isNaN(timeoutID) && users === 1) {
|
||||
timeoutID = setTimeout(stepBatchOfBlocks, 0);
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
users -= 1;
|
||||
|
||||
if (!Number.isNaN(timeoutID) && users === 0) {
|
||||
window.clearTimeout(timeoutID);
|
||||
timeoutID = NaN;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function compressBlock(channels: Float32Array[], blockSize: number): number {
|
||||
let peak = 0.0;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
for (let channel = 0; channel < channels.length; channel++) {
|
||||
peak = Math.max(peak, Math.abs(channels[channel]![i]!));
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
|
@ -26,6 +26,20 @@ function clear() {
|
|||
</div>
|
||||
</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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ScrollablePanel from "@/components/library/panel/ScrollablePanel.vue";
|
||||
import Construction from "@material-design-icons/svg/round/construction.svg";
|
||||
import { computed, shallowRef } from "vue";
|
||||
import AudioTrack from "./views/AudioTrack.vue";
|
||||
import { useTimelineStore } from "@/store/TimelineStore";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
// TODO: use selection (inspector?) manager
|
||||
const selection = shallowRef<object | null>({});
|
||||
|
||||
const timeline = useTimelineStore();
|
||||
const { audioTrack, tracksMap } = storeToRefs(timeline);
|
||||
|
||||
const introClip = computed(() => tracksMap.value.intro.clips[0]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- inspector panel -->
|
||||
<ScrollablePanel class="tw:flex-none tw:min-w-80 tw:max-w-80 tw:border-s">
|
||||
|
||||
<template #toolbar>
|
||||
<h3 class="tw:flex tw:flex-row tw:items-center tw:gap-2 tw:px-4 tw:py-1 tw:select-none">
|
||||
<Construction class="tw:fill-current tw:h-5 tw:w-5 toolbar-icon-shadow" />
|
||||
Inspector
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<!-- inspector content -->
|
||||
<div class="tw:px-4 tw:h-full tw:flex tw:flex-col" @click="selection = selection ? {} : {}">
|
||||
|
||||
<!-- nothing to inspect -->
|
||||
<div v-if="!selection"
|
||||
class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:text-2xl tw:text-[#43474d] tw:select-none">
|
||||
Nothing to inspect
|
||||
</div>
|
||||
|
||||
<!-- inspect selection -->
|
||||
<div v-else class="tw:flex-1 tw:flex tw:flex-col tw:gap-4 tw:py-2 tw:text-xs">
|
||||
<AudioTrack v-if="audioTrack" :audioTrack />
|
||||
<!-- <Clip v-if="introClip" :clip="introClip" /> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</ScrollablePanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Control } from "@/components/inspector/controls";
|
||||
import { computed } from "vue";
|
||||
import { getComponentFor } from "./impl";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: Control;
|
||||
}>();
|
||||
|
||||
const view = computed(() => getComponentFor(control));
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="view" :control="control" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Controls } from "../controls";
|
||||
import Control from "./Control.vue";
|
||||
|
||||
const {
|
||||
controls,
|
||||
} = defineProps<{
|
||||
controls: Controls;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:w-full tw:grid tw:gap-x-2 tw:gap-y-1 tw:py-2 tw:items-baseline"
|
||||
style="grid-template-columns: 80px minmax(0, 1fr);">
|
||||
<Control v-for="control in controls" :key="control.key" :control />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { BaseNamedControl } from "@/components/inspector/controls";
|
||||
|
||||
const {
|
||||
control,
|
||||
id,
|
||||
} = defineProps<{
|
||||
control: BaseNamedControl;
|
||||
/**
|
||||
* Input ID for an associated label.
|
||||
*/
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
// TODO: reset function
|
||||
function reset(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- label -->
|
||||
<label :for="id" class="tw:text-right control-label" :class="{ 'control-label__disabled': control.disabled }"
|
||||
@dblclick="reset">
|
||||
{{ control.name }}
|
||||
</label>
|
||||
|
||||
<!-- control -->
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { ButtonControl } from "@/components/inspector/controls";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: ButtonControl;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control>
|
||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-center tw:justify-start">
|
||||
<button type="button" @click="control.action" :disabled="control.disabled" class="control-button">
|
||||
<component v-if="control.icon" :is="control.icon" class="control-button__icon" />
|
||||
<span class="control-button__text">{{ control.text }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { CheckboxControl } from "@/components/inspector/controls";
|
||||
import { useId } from "vue";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: CheckboxControl;
|
||||
}>();
|
||||
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control :id>
|
||||
<label class="tw:flex tw:flex-row tw:gap-1 tw:items-baseline control-label"
|
||||
:class="{ 'control-label__disabled': control.disabled }">
|
||||
<input type="checkbox" v-model="control.ref" :id :disabled="control.disabled" />
|
||||
<component :is="control.icon" class="tw:flex-none tw:w-4 tw:h-4 tw:fill-current tw:self-center" />
|
||||
<span v-if="control.label">
|
||||
{{ control.label }}
|
||||
</span>
|
||||
</label>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropDownControl } from "@/components/inspector/controls";
|
||||
import { useId } from "vue";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: DropDownControl;
|
||||
}>();
|
||||
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control :id>
|
||||
<div>
|
||||
<select :id v-model="control.ref.value" :disabled="control.disabled"
|
||||
class="tw:w-full tw:max-w-full control-select">
|
||||
<option v-for="option in control.options" :value="option">
|
||||
{{ option }}
|
||||
<!-- and very long text what gonna happen -->
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { HrControl } from "@/components/inspector/controls";
|
||||
|
||||
defineProps<{
|
||||
control: HrControl;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:col-span-full tw:py-2">
|
||||
<hr class="tw:w-full" style="color: var(--inspector-section-separator-color);" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { BaseControl } from "@/components/inspector/controls";
|
||||
|
||||
defineProps<{
|
||||
control: BaseControl;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:col-span-full" >Not Implemented: {{ control.kind }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { NumberControl } from "@/components/inspector/controls";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
import { useId } from "vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: NumberControl;
|
||||
}>();
|
||||
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control :id>
|
||||
<div>
|
||||
<input :id type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
||||
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-20" />
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { RangeControl } from "@/components/inspector/controls";
|
||||
import Slider from "@/components/library/Slider.vue";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
import { useId } from "vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: RangeControl;
|
||||
}>();
|
||||
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control :id>
|
||||
<div class="tw:flex tw:flex-row tw:gap-2 tw:items-baseline">
|
||||
<Slider :id v-model.number="control.ref.value"
|
||||
@update:model-value="(value) => control.ref.value = value ?? control.default" :min="control.min"
|
||||
:max="control.max" :step="0.01" :defaultValue="0" :disabled="control.disabled || control.readonly"
|
||||
class="tw:flex-1 tw:self-end" />
|
||||
<input type="number" v-model.number="control.ref.value" :min="control.min" :max="control.max" :step="0.01"
|
||||
:disabled="control.disabled" :readonly="control.readonly" class="input-text input-number tw:w-14" />
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { TextControl } from "@/components/inspector/controls";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: TextControl;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control>
|
||||
<div>
|
||||
<textarea rows="4" v-model="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
||||
class="tw:w-full tw:max-w-full tw:block tw:resize-none input-text" :class="{ 'tw:font-mono': control.monospace }"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { TextControl } from "@/components/inspector/controls";
|
||||
import BaseNamedControlView from "./BaseNamedControlView.vue";
|
||||
|
||||
const {
|
||||
control,
|
||||
} = defineProps<{
|
||||
control: TextControl;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseNamedControlView :control>
|
||||
<div class="input-text">
|
||||
<input type="text" :value="control.ref.value" :disabled="control.disabled" :readonly="control.readonly"
|
||||
class="tw:w-full tw:max-w-full" />
|
||||
</div>
|
||||
</BaseNamedControlView>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { Component } from "vue";
|
||||
import type { Control } from "..";
|
||||
import ButtonControlView from "./ButtonControlView.vue";
|
||||
import CheckboxControlView from "./CheckboxControlView.vue";
|
||||
import DropDownControlView from "./DropDownControlView.vue";
|
||||
import HrControlView from "./HrControlView.vue";
|
||||
import NotImplementedControlView from "./NotImplementedControlView.vue";
|
||||
import NumberControlView from "./NumberControlView.vue";
|
||||
import RangeControlView from "./RangeControlView.vue";
|
||||
import TextAreaControlView from "./TextAreaControlView.vue";
|
||||
import TextControlView from "./TextControlView.vue";
|
||||
|
||||
/**
|
||||
* Mapping from `control.kind` to the component that renders it.
|
||||
*/
|
||||
const viewMap: Record<Control["kind"], Component> = {
|
||||
hr: HrControlView,
|
||||
text: TextControlView,
|
||||
textarea: TextAreaControlView,
|
||||
number: NumberControlView,
|
||||
range: RangeControlView,
|
||||
checkbox: CheckboxControlView,
|
||||
dropdown: DropDownControlView,
|
||||
button: ButtonControlView,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map `control.kind` to the component that renders it.
|
||||
*
|
||||
* @returns A Component that expects a single `control` property of the same kind as the one passing into this function.
|
||||
*/
|
||||
export function getComponentFor<T extends Control>(control: T): Component {
|
||||
return viewMap[control.kind] ?? NotImplementedControlView;
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import type { Component, Ref } from "vue";
|
||||
|
||||
export interface BaseControl {
|
||||
/**
|
||||
* Discriminator for different types of controls.
|
||||
*/
|
||||
kind: string;
|
||||
/**
|
||||
* Unique key of the control.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface HrControl extends BaseControl {
|
||||
kind: "hr";
|
||||
}
|
||||
|
||||
export interface BaseNamedControl extends BaseControl {
|
||||
/**
|
||||
* Control's name, displayed on the left of the control view itself. Double click it to reset.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* An Icon component to display inline with a label.
|
||||
*/
|
||||
icon?: string | Component;
|
||||
/**
|
||||
* Whether the control is disabled as a whole. Dims the label and implies readonly.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Whether the value should be allowed to change.
|
||||
*/
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseTextControl extends BaseNamedControl {
|
||||
ref: Ref<string>;
|
||||
/** Whether to use monospace font. Defaults to false. */
|
||||
monospace?: boolean;
|
||||
}
|
||||
|
||||
export interface TextControl extends BaseTextControl {
|
||||
kind: "text";
|
||||
}
|
||||
|
||||
export interface TextAreaControl extends BaseTextControl {
|
||||
kind: "textarea";
|
||||
}
|
||||
|
||||
export interface BaseNumberControl extends BaseNamedControl {
|
||||
min: number;
|
||||
max: number;
|
||||
default: number;
|
||||
ref: Ref<number>;
|
||||
}
|
||||
|
||||
/** A range slider accompanied by an input field. */
|
||||
export interface RangeControl extends BaseNumberControl {
|
||||
kind: "range";
|
||||
}
|
||||
|
||||
/** A text input field for a number. */
|
||||
export interface NumberControl extends BaseNumberControl {
|
||||
kind: "number";
|
||||
}
|
||||
|
||||
export interface CheckboxControl extends BaseNamedControl {
|
||||
kind: "checkbox";
|
||||
/** Optional additional label for the checkbox input */
|
||||
label?: string;
|
||||
ref: Ref<boolean>;
|
||||
}
|
||||
|
||||
export interface DropDownControl extends BaseNamedControl {
|
||||
kind: "dropdown";
|
||||
options: readonly string[];
|
||||
ref: Ref<string>;
|
||||
}
|
||||
|
||||
export interface ButtonControl extends BaseNamedControl {
|
||||
kind: "button";
|
||||
/** Unlike control's name label, this property is text on the button itself. */
|
||||
text: string;
|
||||
/** Called when the button is pressed. */
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export type Control =
|
||||
| HrControl
|
||||
| TextControl
|
||||
| TextAreaControl
|
||||
| RangeControl
|
||||
| NumberControl
|
||||
| CheckboxControl
|
||||
| DropDownControl
|
||||
| ButtonControl;
|
||||
|
||||
export type Controls = Control[];
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { AudioTrack } from "@/lib/AudioTrack";
|
||||
import * as Easing from "@/lib/easing";
|
||||
import { ref } from "vue";
|
||||
import type { Controls } from "../controls";
|
||||
import ControlsView from "../controls/ControlsView.vue";
|
||||
import Explicit from '@material-design-icons/svg/filled/explicit.svg';
|
||||
|
||||
const {
|
||||
audioTrack,
|
||||
} = defineProps<{
|
||||
audioTrack: AudioTrack,
|
||||
}>();
|
||||
|
||||
const easing = ref(audioTrack.ColorTransitionEasing);
|
||||
|
||||
const controls: Controls = [
|
||||
{
|
||||
kind: "text",
|
||||
key: "Name",
|
||||
name: "Name",
|
||||
ref: ref(audioTrack.Name),
|
||||
readonly: true,
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
key: "Artist",
|
||||
name: "Artist",
|
||||
ref: ref(audioTrack.Artist),
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
key: "Song",
|
||||
name: "Song",
|
||||
ref: ref(audioTrack.Song),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
kind: "checkbox",
|
||||
key: "IsExplicit",
|
||||
name: "Is Explicit",
|
||||
icon: Explicit,
|
||||
ref: ref(audioTrack.IsExplicit),
|
||||
label: "Explicit",
|
||||
},
|
||||
{
|
||||
kind: "hr",
|
||||
key: "audioTrack.hr.1",
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "BeatsOffset",
|
||||
name: "Beats Offset",
|
||||
min: -0.5,
|
||||
max: 0.5,
|
||||
default: 0,
|
||||
ref: ref(audioTrack.BeatsOffset),
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "LoopOffset",
|
||||
name: "Loop Offset",
|
||||
disabled: true,
|
||||
min: 0,
|
||||
max: 128,
|
||||
default: 0,
|
||||
ref: ref(audioTrack.LoopOffset),
|
||||
},
|
||||
{
|
||||
kind: "dropdown",
|
||||
key: "Easing",
|
||||
name: "Easing",
|
||||
readonly: true,
|
||||
ref: easing,
|
||||
options: Easing.allNames,
|
||||
},
|
||||
// TODO: remove
|
||||
// {
|
||||
// kind: "dropdown",
|
||||
// key: "Easing2",
|
||||
// name: "Easing",
|
||||
// readonly: true,
|
||||
// ref: easing,
|
||||
// options: Easing.allNames,
|
||||
// disabled: true,
|
||||
// },
|
||||
{
|
||||
kind: "number",
|
||||
key: "LyricsIn",
|
||||
name: "Lyrics In",
|
||||
ref: ref(audioTrack.Lyrics[0]?.[0] ?? 0),
|
||||
min: 0,
|
||||
max: 1000,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
kind: "textarea",
|
||||
key: "LyricsText",
|
||||
name: "Lyrics Text",
|
||||
ref: ref(audioTrack.Lyrics[0]?.[1] ?? ""),
|
||||
monospace: true,
|
||||
readonly: false,
|
||||
},
|
||||
{
|
||||
kind: "textarea",
|
||||
key: "Lyrics2",
|
||||
name: "Lyrics2",
|
||||
ref: ref(audioTrack.Lyrics[1]?.[1] ?? ""),
|
||||
monospace: true,
|
||||
disabled: true,
|
||||
readonly: true,
|
||||
},
|
||||
{
|
||||
kind: "button",
|
||||
key: "Clear",
|
||||
name: "",
|
||||
text: "Clear",
|
||||
icon: Explicit,
|
||||
action: () => {
|
||||
console.log("Trigger death screen");
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
kind: "button",
|
||||
key: "Clear2",
|
||||
name: "",
|
||||
text: "Trigger death screen",
|
||||
// icon: Explicit,
|
||||
action: () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "FadeOutBeat",
|
||||
name: "Fade Out Beat",
|
||||
min: -1000,
|
||||
max: 1000,
|
||||
default: -2,
|
||||
ref: ref(audioTrack.FadeOutBeat),
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "FadeOutDuration",
|
||||
name: "Fade Out Duration",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
default: 2,
|
||||
ref: ref(audioTrack.FadeOutDuration),
|
||||
disabled: true,
|
||||
},
|
||||
// {
|
||||
// kind: "number",
|
||||
// key: "FadeOutDuration2",
|
||||
// name: "Fade Out Duration",
|
||||
// min: 0,
|
||||
// max: 1000,
|
||||
// default: 2,
|
||||
// ref: ref(audioTrack.FadeOutDuration),
|
||||
// readonly: true,
|
||||
// },
|
||||
// {
|
||||
// kind: "number",
|
||||
// key: "FadeOutDuration3",
|
||||
// name: "Fade Out Duration",
|
||||
// min: 0,
|
||||
// max: 1000,
|
||||
// default: 2,
|
||||
// ref: ref(audioTrack.FadeOutDuration),
|
||||
// disabled: true,
|
||||
// readonly: true,
|
||||
// },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:select-none">
|
||||
<h3 class="tw:text-sm">Audio Track</h3>
|
||||
<ControlsView :controls />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -75,7 +75,7 @@ const filteredIsEmpty = computed(() => filteredGroupedSortedTracks.value.length
|
|||
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
|
||||
<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));
|
||||
">
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** Slider's orientation */
|
||||
export type Orientation = "horizontal" | "vertical";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ToolBar from "./ToolBar.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:h-full tw:bg-(--main-background-color) tw:border-(--view-separator-color) tw:flex tw:flex-col">
|
||||
|
||||
<ToolBar v-if="$slots.toolbar">
|
||||
<slot name="toolbar" />
|
||||
</ToolBar>
|
||||
|
||||
<div class="tw:flex-1 tw:min-h-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Panel from "./Panel.vue";
|
||||
import ShadowedScrollView from "./ShadowedScrollView.vue";
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel>
|
||||
<template #toolbar>
|
||||
<slot name="toolbar" />
|
||||
</template>
|
||||
<ShadowedScrollView class="tw:h-full">
|
||||
<slot />
|
||||
</ShadowedScrollView>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useInterval, useScroll } from "@vueuse/core";
|
||||
import { useTemplateRef } from "vue";
|
||||
|
||||
const scrollView = useTemplateRef('scrollView');
|
||||
|
||||
const { arrivedState, measure } = useScroll(scrollView);
|
||||
|
||||
// useScroll.arrivedState can get stale,
|
||||
// see: https://github.com/vueuse/vueuse/issues/4265#issuecomment-3618168624
|
||||
// useInterval(2000, {
|
||||
// callback: () => {
|
||||
// console.log("MEASURE");
|
||||
// measure();
|
||||
// }
|
||||
// });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:min-h-0 tw:min-w-0 tw:grid tw:grid-rows-1 tw:grid-cols-1">
|
||||
<!-- scrollable content view -->
|
||||
<div ref="scrollView" class="tw:overflow-scroll tw:min-h-0" style="grid-row: 1; grid-column: 1;">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- bars of scroll shadow, on top of content -->
|
||||
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1; grid-column: 1;">
|
||||
|
||||
<!-- top shadow -->
|
||||
<div class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.top }">
|
||||
<div class="tw:h-4 tw:w-full shadow-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- bottom shadow -->
|
||||
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full" :class="{ 'tw:invisible': arrivedState.bottom }">
|
||||
<div class="tw:h-4 tw:w-full shadow-top"></div>
|
||||
</div>
|
||||
|
||||
<!-- left shadow -->
|
||||
<div class="tw:absolute tw:left-0 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.left }">
|
||||
<div class="tw:w-4 tw:h-full shadow-right"></div>
|
||||
</div>
|
||||
|
||||
<!-- right shadow -->
|
||||
<div class="tw:absolute tw:right-4 tw:top-0 tw:w-0 tw:h-full" :class="{ 'tw:invisible': arrivedState.right }">
|
||||
<div class="tw:w-4 tw:h-full shadow-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shadow-top,
|
||||
.shadow-right,
|
||||
.shadow-bottom,
|
||||
.shadow-left {
|
||||
--shadow-darkest: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.shadow-top {
|
||||
background-image: linear-gradient(to top, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-right {
|
||||
background-image: linear-gradient(to right, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-bottom {
|
||||
background-image: linear-gradient(to bottom, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
|
||||
.shadow-left {
|
||||
background-image: linear-gradient(to left, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw:bg-(--toolbar-background-color) tw:border-b tw:border-(--view-separator-color)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -51,6 +51,5 @@ const viewportSide = computed(() => timeline.viewportSide(positionSeconds));
|
|||
top: 0;
|
||||
will-change: transform, visibility;
|
||||
/* pointer-events: none; */
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<TimelineHeader />
|
||||
|
||||
<!-- <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,
|
||||
|
|
|
|||
|
|
@ -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 tw:flex-row tw:max-sm:flex-col tw:items-center tw:justify-center tw:gap-x-4 tw:gap-y-2 tw:px-4 tw:max-sm:px-2 tw:py-1">
|
||||
<div
|
||||
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:border-(--view-separator-color)">
|
||||
<ToolButton :icon="Replay" @click="rewindToIntro" title="Rewind to Intro" />
|
||||
<ToolButton :icon="Restart" @click="rewindToWindUp"
|
||||
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
|
||||
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
|
||||
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'" @click="toggle" />
|
||||
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
|
||||
</div>
|
||||
<div class="tw:flex-1 tw:max-sm:w-full tw:flex tw:flex-row tw:gap-x-2">
|
||||
<Timestamp :seconds="timeline.playheadPosition" :beats="timeline.playheadPositionLoopOffsetBeats" />
|
||||
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
|
||||
{{ audioTrack?.Name }}
|
||||
</div>
|
||||
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
|
||||
<ToolToggle :checked="rightSidebar.visible.value" :icon="ViewSidebar" @click="rightSidebar.toggle()"
|
||||
:title="rightSidebar.toggleActionString.value" />
|
||||
</div>
|
||||
class="tw:flex-initial tw:max-sm:w-full tw:flex tw:flex-row tw:max-sm:border-b tw:max-sm:border-b-(--view-separator-color)">
|
||||
<ToolButton :icon="Replay" @click="rewindToIntro" title="Rewind to Intro" />
|
||||
<ToolButton :icon="Restart" @click="rewindToWindUp"
|
||||
:title="hasLoopOffset ? 'Rewind to Wind-up' : 'Rewind to Wind-up / Loop'" />
|
||||
<ToolButton :icon="Restart" @click="rewindToLoop" title="Rewind to Loop" v-if="hasLoopOffset" />
|
||||
<ToolButton :icon="isPlaying ? Pause : Play" :title="isPlaying ? 'Pause' : 'Play'"
|
||||
@click="trackStore.togglePlayPause()" />
|
||||
<MasterVolumeSlider class="tw:max-sm:flex-1 tw:pe-2 tw:min-w-40" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Timeline class="tw:min-h-0 tw:size-full" :rightSidebar />
|
||||
</Panel>
|
||||
|
||||
<div class="tw:flex-1 tw:max-sm:w-full tw:flex tw:flex-row tw:gap-x-2">
|
||||
<Timestamp :seconds="timeline.playheadPosition" :beats="timeline.playheadPositionBeats" />
|
||||
<div class="description tw:min-w-0 tw:text-center tw:self-center tw:font-bold tw:truncate">
|
||||
{{ currentAudioTrack?.Name }}
|
||||
</div>
|
||||
<Timestamp :seconds="timeline.duration" :beats="timeline.durationBeats" />
|
||||
<ToolToggle :checked="rightSidebar.visible.value" :icon="ViewSidebar" @click="rightSidebar.toggle()"
|
||||
:title="rightSidebar.toggleActionString.value" />
|
||||
</div>
|
||||
</div>
|
||||
<Timeline class="tw:flex-1 tw:min-h-0" :rightSidebar />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.description {
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { timelineClipLabel, type TimelineClipData, type TimelineTrackData } from '@/lib/Timeline';
|
||||
import { computed } from 'vue';
|
||||
import BottomLine from './BottomLine.vue';
|
||||
import AudioWaveform from './AudioWaveform.vue';
|
||||
|
||||
const {
|
||||
track,
|
||||
clip,
|
||||
width,
|
||||
} = defineProps<{
|
||||
track: TimelineTrackData,
|
||||
clip: TimelineClipData,
|
||||
width: number,
|
||||
}>();
|
||||
|
||||
const label = computed(() => timelineClipLabel(track, clip));
|
||||
</script>
|
||||
<template>
|
||||
<!-- waveform -->
|
||||
<div v-if="clip.audioBuffer !== undefined" class="waveform-wrapper">
|
||||
<div class="waveform-content tw:overflow-hidden">
|
||||
<AudioWaveform :buffer="clip.audioBuffer" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- clip label -->
|
||||
<div class="label-wrapper">
|
||||
<div class="label-content tw:truncate" :style="{ display: width < 22 ? 'none' : undefined }" :title="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- clip line -->
|
||||
<BottomLine />
|
||||
</template>
|
||||
<style scoped>
|
||||
.waveform-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 2px;
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
/* same as bottom line */
|
||||
padding-bottom: calc(var(--tw-spacing) * 5.5 + 2px);
|
||||
}
|
||||
|
||||
.waveform-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.label-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 2px;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.label-content {
|
||||
background-color: var(--timeline-clip-label-background-color);
|
||||
outline: 1px solid var(--timeline-clip-label-border-color);
|
||||
border-radius: 3px;
|
||||
text-align: start;
|
||||
max-width: fit-content;
|
||||
padding: 0 2px;
|
||||
font-size: 8pt;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useWaveform } from '@/audio/AudioWaveform';
|
||||
import { unrefElement, useResizeObserver, useThrottleFn } from '@vueuse/core';
|
||||
import { shallowRef, useTemplateRef, watchEffect } from 'vue';
|
||||
|
||||
const {
|
||||
buffer,
|
||||
} = defineProps<{
|
||||
buffer: AudioBuffer,
|
||||
}>();
|
||||
|
||||
const canvas = useTemplateRef('canvas');
|
||||
const canvasWidth = shallowRef(0);
|
||||
|
||||
// TODO: only render what's visible on the timeline.
|
||||
// Currently at max zoom canvas may exceed 32_000 px width which browser refuses to render.
|
||||
|
||||
const waveform = useWaveform(() => buffer, canvasWidth);
|
||||
|
||||
const resizeObserver: globalThis.ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => {
|
||||
const c = unrefElement(canvas);
|
||||
if (!c) return;
|
||||
|
||||
const ctx = c.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const entry = entries.filter(entry => entry.target === c)[0];
|
||||
if (!entry) return;
|
||||
|
||||
// get the size from the ResizeObserverEntry (contentRect) and handle
|
||||
// devicePixelRatio so the canvas looks sharp on HiDPI screens
|
||||
const rect = entry.contentRect || c.getBoundingClientRect();
|
||||
const cssWidth = rect.width;
|
||||
const cssHeight = rect.height;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// set internal canvas size in device pixels
|
||||
c.width = Math.max(1, Math.round(cssWidth * dpr));
|
||||
c.height = Math.max(1, Math.round(cssHeight * dpr));
|
||||
|
||||
canvasWidth.value = c.width;
|
||||
|
||||
redraw(waveform.isDone.value, waveform.peaks.value);
|
||||
}
|
||||
|
||||
let peakHeights = new Uint32Array(0);
|
||||
|
||||
const redraw = useThrottleFn((isDone: boolean, peaks: Float32Array) => {
|
||||
const c = unrefElement(canvas);
|
||||
if (!c) return;
|
||||
|
||||
const ctx = c.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = c.width;
|
||||
const halfHeight = Math.floor(c.height / 2);
|
||||
|
||||
if (peakHeights.length != width) {
|
||||
peakHeights = new Uint32Array(width);
|
||||
}
|
||||
|
||||
const scale = 1.75;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
// audio tracks are normalized to a peak -14 dBFS, so we need to stretch them up to take up reasonable space
|
||||
const peakHeight = Math.min(1, (peaks[x] ?? 0) * scale);
|
||||
const height = Math.round(peakHeight * halfHeight);
|
||||
peakHeights[x] = height;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
|
||||
ctx.fillStyle = "#ffffffd8";
|
||||
ctx.strokeStyle = "transparent";
|
||||
|
||||
// fill first, slanted outline next
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const height = peakHeights[x]!;
|
||||
// draw vertically centered
|
||||
const y = Math.round(halfHeight - height);
|
||||
ctx.fillRect(x, y, 1, height * 2);
|
||||
}
|
||||
|
||||
// outline
|
||||
ctx.fillStyle = "transparent";
|
||||
ctx.strokeStyle = "#00000080";
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
for (const sign of [-1, 1]) {
|
||||
ctx.moveTo(0, peakHeights[0] ?? 0);
|
||||
|
||||
for (let x = 1; x < width; x += 1) {
|
||||
const height = peakHeights[x]!;
|
||||
const y = sign * height + halfHeight;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// middle line
|
||||
ctx.fillStyle = "#a1a998";
|
||||
ctx.fillRect(0, Math.round(halfHeight), c.width, 1);
|
||||
|
||||
ctx.restore();
|
||||
}, 0);
|
||||
|
||||
useResizeObserver(canvas, resizeObserver);
|
||||
|
||||
watchEffect(() => {
|
||||
redraw(waveform.isDone.value, waveform.peaks.value);
|
||||
}, { flush: 'sync' });
|
||||
</script>
|
||||
<template>
|
||||
<canvas ref="canvas" class="tw:size-full">
|
||||
</canvas>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
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" />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<svg viewBox="0 0 11 16" fill="currentColor">
|
||||
<path d="M 3 1 h 5 a 2 2 0 0 1 2 2 v 7.5 l -4.5 4.5 l -4.5 -4.5 V 3 a 2 2 0 0 1 2 -2 Z"
|
||||
stroke="var(--marker-stroke-color)" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 191 B |
|
|
@ -1,10 +1,3 @@
|
|||
import type { EasingName } from "@/lib/easing";
|
||||
import { modRange } from "@/lib/math";
|
||||
import type { AnyTime, Beats, Seconds } from "@/lib/units";
|
||||
import { useSetterRef } from "@/lib/vue";
|
||||
import { clamp } from "@vueuse/core";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
export const LANGUAGES = [
|
||||
"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);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { formatBeats, formatTime } from ".";
|
||||
|
||||
describe("format time", () => {
|
||||
test("baseline", () => {
|
||||
expect(formatTime).toBeDefined();
|
||||
expect(() => formatTime(0)).not.toThrow();
|
||||
});
|
||||
test("default", () => {
|
||||
expect(formatTime(0)).toBe("0:00.000");
|
||||
});
|
||||
test("precision limits", () => {
|
||||
expect(formatTime(0, 0)).toBe("0:00");
|
||||
expect(formatTime(0, 1)).toBe("0:00.0");
|
||||
expect(formatTime(0, 2)).toBe("0:00.00");
|
||||
expect(formatTime(0, 3)).toBe("0:00.000");
|
||||
expect(formatTime(0, 4)).toBe("0:00.000");
|
||||
});
|
||||
test("valid inputs", () => {
|
||||
expect(formatTime(0)).toBe("0:00.000");
|
||||
expect(formatTime(1)).toBe("0:01.000");
|
||||
expect(formatTime(12)).toBe("0:12.000");
|
||||
expect(formatTime(0.001, 3)).toBe("0:00.001");
|
||||
expect(formatTime(0.001, 2)).toBe("0:00.00");
|
||||
expect(formatTime(0.123)).toBe("0:00.123");
|
||||
expect(formatTime(60)).toBe("1:00.000");
|
||||
expect(formatTime(61)).toBe("1:01.000");
|
||||
expect(formatTime(123.456)).toBe("2:03.456");
|
||||
expect(formatTime(-123.456)).toBe("-2:03.456");
|
||||
});
|
||||
});
|
||||
describe("format beats", () => {
|
||||
test("baseline", () => {
|
||||
expect(formatBeats).toBeDefined();
|
||||
expect(() => formatBeats(0)).not.toThrow();
|
||||
});
|
||||
test("default", () => {
|
||||
expect(formatBeats(0)).toBe("00.000");
|
||||
});
|
||||
test("precision limits", () => {
|
||||
expect(formatBeats(0, 0)).toBe("00");
|
||||
expect(formatBeats(0, 1)).toBe("00.0");
|
||||
expect(formatBeats(0, 2)).toBe("00.00");
|
||||
expect(formatBeats(0, 3)).toBe("00.000");
|
||||
expect(formatBeats(0, 4)).toBe("00.000");
|
||||
});
|
||||
test("minimum padding", () => {
|
||||
expect(formatBeats(0, 0)).toBe("00");
|
||||
expect(formatBeats(1, 0)).toBe("01");
|
||||
expect(formatBeats(10, 0)).toBe("10");
|
||||
expect(formatBeats(20, 0)).toBe("20");
|
||||
expect(formatBeats(100, 0)).toBe("100");
|
||||
});
|
||||
test("valid inputs", () => {
|
||||
expect(formatBeats(0)).toBe("00.000");
|
||||
expect(formatBeats(1)).toBe("01.000");
|
||||
expect(formatBeats(12)).toBe("12.000");
|
||||
expect(formatBeats(0.001, 3)).toBe("00.001");
|
||||
expect(formatBeats(0.001, 2)).toBe("00.00");
|
||||
expect(formatBeats(0.123)).toBe("00.123");
|
||||
expect(formatBeats(61)).toBe("61.000");
|
||||
expect(formatBeats(123.456)).toBe("123.456");
|
||||
expect(formatBeats(-123.456)).toBe("-123.456");
|
||||
});
|
||||
});
|
||||
|
|
@ -3,12 +3,10 @@ import {
|
|||
beatsToSeconds,
|
||||
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,
|
||||
reference: "loop",
|
||||
markerIn: i,
|
||||
position: "bottom",
|
||||
});
|
||||
}
|
||||
markers.push({
|
||||
name: "Beat",
|
||||
color: namedColors.teal,
|
||||
reference: "loop",
|
||||
markerIn: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toAbsoluteDuration(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export default [
|
||||
"#eb6e01",
|
||||
"#ffa833",
|
||||
"#d4ad1f",
|
||||
"#9fc615",
|
||||
"#5f9921",
|
||||
"#448f65",
|
||||
"#019899",
|
||||
"#005278",
|
||||
"#4376a1",
|
||||
"#9972a0",
|
||||
"#d0568d",
|
||||
"#e98cb5",
|
||||
"#b9af97",
|
||||
"#c4a07c",
|
||||
"#996601",
|
||||
"#8c5a3f",
|
||||
] as const;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export default [
|
||||
"var(--timeline-clip-color-orange)",
|
||||
"var(--timeline-clip-color-apricot)",
|
||||
"var(--timeline-clip-color-yellow)",
|
||||
"var(--timeline-clip-color-lime)",
|
||||
"var(--timeline-clip-color-olive)",
|
||||
"var(--timeline-clip-color-green)",
|
||||
"var(--timeline-clip-color-teal)",
|
||||
"var(--timeline-clip-color-navy)",
|
||||
"var(--timeline-clip-color-blue)",
|
||||
"var(--timeline-clip-color-purple)",
|
||||
"var(--timeline-clip-color-violet)",
|
||||
"var(--timeline-clip-color-pink)",
|
||||
"var(--timeline-clip-color-tan)",
|
||||
"var(--timeline-clip-color-beige)",
|
||||
"var(--timeline-clip-color-brown)",
|
||||
"var(--timeline-clip-color-chocolate)",
|
||||
] as const;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
export const namedHex = {
|
||||
orange: "#eb6e01",
|
||||
apricot: "#ffa833",
|
||||
yellow: "#d4ad1f",
|
||||
lime: "#9fc615",
|
||||
olive: "#5f9921",
|
||||
green: "#448f65",
|
||||
teal: "#019899",
|
||||
navy: "#005278",
|
||||
blue: "#4376a1",
|
||||
purple: "#9972a0",
|
||||
violet: "#d0568d",
|
||||
pink: "#e98cb5",
|
||||
tan: "#b9af97",
|
||||
beige: "#c4a07c",
|
||||
brown: "#996601",
|
||||
chocolate: "#8c5a3f",
|
||||
} as const;
|
||||
|
||||
export const arrayHex = [
|
||||
"#eb6e01",
|
||||
"#ffa833",
|
||||
"#d4ad1f",
|
||||
"#9fc615",
|
||||
"#5f9921",
|
||||
"#448f65",
|
||||
"#019899",
|
||||
"#005278",
|
||||
"#4376a1",
|
||||
"#9972a0",
|
||||
"#d0568d",
|
||||
"#e98cb5",
|
||||
"#b9af97",
|
||||
"#c4a07c",
|
||||
"#996601",
|
||||
"#8c5a3f",
|
||||
] as const;
|
||||
|
||||
export const namedVars = {
|
||||
orange: "var(--timeline-clip-color-orange)",
|
||||
apricot: "var(--timeline-clip-color-apricot)",
|
||||
yellow: "var(--timeline-clip-color-yellow)",
|
||||
lime: "var(--timeline-clip-color-lime)",
|
||||
olive: "var(--timeline-clip-color-olive)",
|
||||
green: "var(--timeline-clip-color-green)",
|
||||
teal: "var(--timeline-clip-color-teal)",
|
||||
navy: "var(--timeline-clip-color-navy)",
|
||||
blue: "var(--timeline-clip-color-blue)",
|
||||
purple: "var(--timeline-clip-color-purple)",
|
||||
violet: "var(--timeline-clip-color-violet)",
|
||||
pink: "var(--timeline-clip-color-pink)",
|
||||
tan: "var(--timeline-clip-color-tan)",
|
||||
beige: "var(--timeline-clip-color-beige)",
|
||||
brown: "var(--timeline-clip-color-brown)",
|
||||
chocolate: "var(--timeline-clip-color-chocolate)",
|
||||
} as const;
|
||||
|
||||
export const arrayVars = [
|
||||
"var(--timeline-clip-color-orange)",
|
||||
"var(--timeline-clip-color-apricot)",
|
||||
"var(--timeline-clip-color-yellow)",
|
||||
"var(--timeline-clip-color-lime)",
|
||||
"var(--timeline-clip-color-olive)",
|
||||
"var(--timeline-clip-color-green)",
|
||||
"var(--timeline-clip-color-teal)",
|
||||
"var(--timeline-clip-color-navy)",
|
||||
"var(--timeline-clip-color-blue)",
|
||||
"var(--timeline-clip-color-purple)",
|
||||
"var(--timeline-clip-color-violet)",
|
||||
"var(--timeline-clip-color-pink)",
|
||||
"var(--timeline-clip-color-tan)",
|
||||
"var(--timeline-clip-color-beige)",
|
||||
"var(--timeline-clip-color-brown)",
|
||||
"var(--timeline-clip-color-chocolate)",
|
||||
] as const;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
export const namedHex = {
|
||||
blue: "#007fe3",
|
||||
cyan: "#00ced0",
|
||||
green: "#00ad00",
|
||||
yellow: "#f09d00",
|
||||
red: "#e12401",
|
||||
pink: "#ff44c8",
|
||||
purple: "#9013fe",
|
||||
fuchsia: "#c02e6f",
|
||||
rose: "#ffa1b9",
|
||||
lavender: "#a193c8",
|
||||
sky: "#a193c8",
|
||||
mint: "#72db00",
|
||||
lemon: "#dce95a",
|
||||
sand: "#c4915e",
|
||||
cocoa: "#6e5143",
|
||||
cream: "#f5ebe1",
|
||||
} as const;
|
||||
|
||||
export const arrayHex = [
|
||||
"#007fe3",
|
||||
"#00ced0",
|
||||
"#00ad00",
|
||||
"#f09d00",
|
||||
"#e12401",
|
||||
"#ff44c8",
|
||||
"#9013fe",
|
||||
"#c02e6f",
|
||||
"#ffa1b9",
|
||||
"#a193c8",
|
||||
"#a193c8",
|
||||
"#72db00",
|
||||
"#dce95a",
|
||||
"#c4915e",
|
||||
"#6e5143",
|
||||
"#f5ebe1",
|
||||
] as const;
|
||||
|
||||
export const namedVars = {
|
||||
blue: "var(--timeline-marker-color-blue)",
|
||||
cyan: "var(--timeline-marker-color-cyan)",
|
||||
green: "var(--timeline-marker-color-green)",
|
||||
yellow: "var(--timeline-marker-color-yellow)",
|
||||
red: "var(--timeline-marker-color-red)",
|
||||
pink: "var(--timeline-marker-color-pink)",
|
||||
purple: "var(--timeline-marker-color-purple)",
|
||||
fuchsia: "var(--timeline-marker-color-fuchsia)",
|
||||
rose: "var(--timeline-marker-color-rose)",
|
||||
lavender: "var(--timeline-marker-color-lavender)",
|
||||
sky: "var(--timeline-marker-color-sky)",
|
||||
mint: "var(--timeline-marker-color-mint)",
|
||||
lemon: "var(--timeline-marker-color-lemon)",
|
||||
sand: "var(--timeline-marker-color-sand)",
|
||||
cocoa: "var(--timeline-marker-color-cocoa)",
|
||||
cream: "var(--timeline-marker-color-cream)",
|
||||
} as const;
|
||||
|
||||
export const arrayVars = [
|
||||
"var(--timeline-marker-color-blue)",
|
||||
"var(--timeline-marker-color-cyan)",
|
||||
"var(--timeline-marker-color-green)",
|
||||
"var(--timeline-marker-color-yellow)",
|
||||
"var(--timeline-marker-color-red)",
|
||||
"var(--timeline-marker-color-pink)",
|
||||
"var(--timeline-marker-color-purple)",
|
||||
"var(--timeline-marker-color-fuchsia)",
|
||||
"var(--timeline-marker-color-rose)",
|
||||
"var(--timeline-marker-color-lavender)",
|
||||
"var(--timeline-marker-color-sky)",
|
||||
"var(--timeline-marker-color-mint)",
|
||||
"var(--timeline-marker-color-lemon)",
|
||||
"var(--timeline-marker-color-sand)",
|
||||
"var(--timeline-marker-color-cocoa)",
|
||||
"var(--timeline-marker-color-cream)",
|
||||
] as const;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export const orange = "#eb6e01";
|
||||
export const apricot = "#ffa833";
|
||||
export const yellow = "#d4ad1f";
|
||||
export const lime = "#9fc615";
|
||||
export const olive = "#5f9921";
|
||||
export const green = "#448f65";
|
||||
export const teal = "#019899";
|
||||
export const navy = "#005278";
|
||||
export const blue = "#4376a1";
|
||||
export const purple = "#9972a0";
|
||||
export const violet = "#d0568d";
|
||||
export const pink = "#e98cb5";
|
||||
export const tan = "#b9af97";
|
||||
export const beige = "#c4a07c";
|
||||
export const brown = "#996601";
|
||||
export const chocolate = "#8c5a3f";
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export const orange = "var(--timeline-clip-color-orange)";
|
||||
export const apricot = "var(--timeline-clip-color-apricot)";
|
||||
export const yellow = "var(--timeline-clip-color-yellow)";
|
||||
export const lime = "var(--timeline-clip-color-lime)";
|
||||
export const olive = "var(--timeline-clip-color-olive)";
|
||||
export const green = "var(--timeline-clip-color-green)";
|
||||
export const teal = "var(--timeline-clip-color-teal)";
|
||||
export const navy = "var(--timeline-clip-color-navy)";
|
||||
export const blue = "var(--timeline-clip-color-blue)";
|
||||
export const purple = "var(--timeline-clip-color-purple)";
|
||||
export const violet = "var(--timeline-clip-color-violet)";
|
||||
export const pink = "var(--timeline-clip-color-pink)";
|
||||
export const tan = "var(--timeline-clip-color-tan)";
|
||||
export const beige = "var(--timeline-clip-color-beige)";
|
||||
export const brown = "var(--timeline-clip-color-brown)";
|
||||
export const chocolate = "var(--timeline-clip-color-chocolate)";
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import type { Assertion, JestAssertion } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Easing } from ".";
|
||||
import { all, allNames, findEasingByName } from ".";
|
||||
|
||||
type NumberAssertion = {
|
||||
[K in keyof Assertion<number>]: Assertion<number>[K] extends
|
||||
(it: number) => void ? K : never;
|
||||
}[keyof Assertion<number>];
|
||||
|
||||
function expectEasing(
|
||||
easing: Easing,
|
||||
middle: NumberAssertion,
|
||||
): void {
|
||||
expect(easing.eval(0)).toBe(0);
|
||||
// despite the elaborate typing above, it still doesn't work without an as-cast
|
||||
const ass = expect(easing.eval(0.5));
|
||||
(ass[middle] as (it: number) => void)(0.5);
|
||||
expect(easing.eval(1)).toBe(1);
|
||||
}
|
||||
|
||||
describe("easing", () => {
|
||||
test("baseline", () => {
|
||||
expect(all).toBeDefined();
|
||||
expect(allNames).toBeDefined();
|
||||
expect(findEasingByName).toBeDefined();
|
||||
expect(() => findEasingByName("")).not.toThrow();
|
||||
const easing = findEasingByName("");
|
||||
expect(easing).toBeDefined();
|
||||
expect(easing.name).toBe("Linear");
|
||||
expect(easing.eval).toBeDefined();
|
||||
expect(() => easing.eval(0)).not.toThrow();
|
||||
});
|
||||
test("Linear", () => {
|
||||
const easing = findEasingByName("Linear");
|
||||
expectEasing(easing, "toBe");
|
||||
});
|
||||
test("InCubic", () => {
|
||||
const easing = findEasingByName("InCubic");
|
||||
expectEasing(easing, "toBeLessThan");
|
||||
});
|
||||
test("OutCubic", () => {
|
||||
const easing = findEasingByName("OutCubic");
|
||||
expectEasing(easing, "toBeGreaterThan");
|
||||
});
|
||||
test("InOutCubic", () => {
|
||||
const easing = findEasingByName("InOutCubic");
|
||||
expectEasing(easing, "toBe");
|
||||
});
|
||||
test("InExpo", () => {
|
||||
const easing = findEasingByName("InExpo");
|
||||
expectEasing(easing, "toBeLessThan");
|
||||
});
|
||||
test("OutExpo", () => {
|
||||
const easing = findEasingByName("OutExpo");
|
||||
expectEasing(easing, "toBeGreaterThan");
|
||||
});
|
||||
test("InOutExpo", () => {
|
||||
const easing = findEasingByName("InOutExpo");
|
||||
expectEasing(easing, "toBe");
|
||||
});
|
||||
test("find", () => {
|
||||
expect(allNames).toContain("OutExpo");
|
||||
expect(allNames).toContain("InOutCubic");
|
||||
for (const name of allNames) {
|
||||
const easing = findEasingByName(name);
|
||||
expect(easing).toBeDefined();
|
||||
expect(easing.name).toBe(name);
|
||||
expect(all).toContain(easing);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
export interface Easing {
|
||||
readonly name: EasingName;
|
||||
readonly eval: (t: number) => number;
|
||||
}
|
||||
|
||||
export type EasingName =
|
||||
| "Linear"
|
||||
| "InCubic"
|
||||
| "OutCubic"
|
||||
| "InOutCubic"
|
||||
| "InExpo"
|
||||
| "OutExpo"
|
||||
| "InOutExpo";
|
||||
|
||||
export const Linear: Easing = {
|
||||
name: "Linear",
|
||||
eval: (x) => x,
|
||||
};
|
||||
|
||||
export const InCubic: Easing = {
|
||||
name: "InCubic",
|
||||
eval: (x) => x * x * x,
|
||||
};
|
||||
|
||||
export const OutCubic: Easing = {
|
||||
name: "OutCubic",
|
||||
eval: (x) => 1 - Math.pow(1 - x, 3),
|
||||
};
|
||||
|
||||
export const InOutCubic: Easing = {
|
||||
name: "InOutCubic",
|
||||
eval: (x) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2,
|
||||
};
|
||||
|
||||
export const InExpo: Easing = {
|
||||
name: "InExpo",
|
||||
eval: (x) => x == 0 ? 0 : Math.pow(2, 10 * x - 10),
|
||||
};
|
||||
|
||||
export const OutExpo: Easing = {
|
||||
name: "OutExpo",
|
||||
eval: (x) => x == 1 ? 1 : 1 - Math.pow(2, -10 * x),
|
||||
};
|
||||
|
||||
export const InOutExpo: Easing = {
|
||||
name: "InOutExpo",
|
||||
eval: (x) =>
|
||||
x == 0
|
||||
? 0
|
||||
: x == 1
|
||||
? 1
|
||||
: x < 0.5
|
||||
? Math.pow(2, 20 * x - 10) / 2
|
||||
: (2 - Math.pow(2, -20 * x + 10)) / 2,
|
||||
};
|
||||
|
||||
export const all: readonly Easing[] = [
|
||||
Linear,
|
||||
InCubic,
|
||||
OutCubic,
|
||||
InOutCubic,
|
||||
InExpo,
|
||||
OutExpo,
|
||||
InOutExpo,
|
||||
];
|
||||
|
||||
export const allNames: readonly EasingName[] = all.map((easing) => easing.name);
|
||||
|
||||
export function findEasingByName(name: EasingName | string): Easing {
|
||||
return all.find((easing) => easing.name == name) ?? Linear;
|
||||
}
|
||||
|
|
@ -28,19 +28,14 @@ function isEditableElement(el?: Element | null): boolean {
|
|||
const tag = elm.tagName;
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
import type { Px, Seconds } from "@/lib/units";
|
||||
import { useTimelineStore } from "@/store/TimelineStore";
|
||||
import {
|
||||
type Arrayable,
|
||||
toArray,
|
||||
tryOnScopeDispose,
|
||||
unrefElement,
|
||||
useEventListener,
|
||||
watchImmediate,
|
||||
} from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import type { MaybeRefOrGetter } from "vue";
|
||||
import { shallowRef, toValue } from "vue";
|
||||
|
||||
export function useTimelineScrubbing(
|
||||
elements: MaybeRefOrGetter<Arrayable<HTMLElement> | null>,
|
||||
) {
|
||||
const cleanups: Function[] = [];
|
||||
const cleanup = () => {
|
||||
cleanups.forEach((fn) => fn());
|
||||
cleanups.length = 0;
|
||||
};
|
||||
|
||||
let wasPlayingBeforeScrubbing = false;
|
||||
const isScrubbing = shallowRef(false);
|
||||
|
||||
const timeline = useTimelineStore();
|
||||
const { player } = storeToRefs(timeline);
|
||||
|
||||
const getTarget = (event: PointerEvent): HTMLElement | null => {
|
||||
const target = event.currentTarget;
|
||||
return target instanceof HTMLElement ? target : null;
|
||||
};
|
||||
|
||||
const seek = (event: PointerEvent, scrub: boolean) => {
|
||||
const target = getTarget(event);
|
||||
const p = player.value;
|
||||
if (!target || !p) return;
|
||||
|
||||
// clientX is broken in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=505521#c80
|
||||
const left: Px = event.pageX - target.getBoundingClientRect().left;
|
||||
const seconds: Seconds = timeline.pixelsToSeconds(left);
|
||||
// apply mouse position
|
||||
p.seek(seconds, { scrub });
|
||||
};
|
||||
|
||||
const logTarget = (event: PointerEvent) => {
|
||||
const t = event.currentTarget;
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
logTarget(event);
|
||||
|
||||
if (isScrubbing.value) {
|
||||
seek(event, true);
|
||||
}
|
||||
};
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
logTarget(event);
|
||||
|
||||
const target = getTarget(event);
|
||||
const p = player.value;
|
||||
if (!target || !p) return;
|
||||
|
||||
// Only the first pointer ID is going to be used for scrubbing,
|
||||
// so there should be no conflicts in case of multi-touch input.
|
||||
if (target && !isScrubbing.value) {
|
||||
isScrubbing.value = true;
|
||||
wasPlayingBeforeScrubbing = p.playback.isPlaying.value;
|
||||
target.setPointerCapture(event.pointerId);
|
||||
seek(event, false);
|
||||
}
|
||||
};
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
logTarget(event);
|
||||
|
||||
const p = player.value;
|
||||
if (!p) return;
|
||||
|
||||
if (isScrubbing.value) {
|
||||
isScrubbing.value = false;
|
||||
seek(event, false);
|
||||
if (wasPlayingBeforeScrubbing) {
|
||||
wasPlayingBeforeScrubbing = false;
|
||||
p.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
const onPointerLeave = (_event: PointerEvent) => {
|
||||
};
|
||||
|
||||
const register = (el: EventTarget) => {
|
||||
return [
|
||||
useEventListener(el, "pointerdown", onPointerDown),
|
||||
useEventListener(el, "pointerup", onPointerUp),
|
||||
useEventListener(el, "pointermove", onPointerMove),
|
||||
useEventListener(el, "pointerleave", onPointerLeave),
|
||||
];
|
||||
};
|
||||
|
||||
const stopWatch = watchImmediate(
|
||||
() =>
|
||||
[
|
||||
toArray(toValue(elements)).filter((e) => e != null).map((e) =>
|
||||
unrefElement(e)!
|
||||
),
|
||||
] as const,
|
||||
([raw_targets]) => {
|
||||
cleanup();
|
||||
|
||||
if (!raw_targets.length) return;
|
||||
|
||||
cleanups.push(
|
||||
...raw_targets.flatMap((el) => register(el)),
|
||||
);
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
const stop = () => {
|
||||
stopWatch();
|
||||
cleanup();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return stop;
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { formatTime } from "@/lib/AudioTrack";
|
||||
import { 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);
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import type { Px, ZoomRaw } from "@/lib/units";
|
||||
import { useEventListener } from "@vueuse/core";
|
||||
import type { MaybeRefOrGetter, Ref } from "vue";
|
||||
import type { UseZoomAxis } from "../useZoomAxis";
|
||||
|
||||
export interface UseVeiwportWheelOptions {
|
||||
axisHorizontal: UseZoomAxis;
|
||||
axisVertical: UseZoomAxis;
|
||||
scrollOffsetLeft: Ref<Px>;
|
||||
}
|
||||
|
||||
export function useVeiwportWheel(
|
||||
target: MaybeRefOrGetter<HTMLElement | null>,
|
||||
options: UseVeiwportWheelOptions,
|
||||
) {
|
||||
function handler(event: WheelEvent) {
|
||||
// Note: Math.random() prevents console output history from collapsing same entries.
|
||||
// console.log("WHEEEEEL", Math.random().toFixed(3), event.deltaX, event.deltaY, event.target, event);
|
||||
|
||||
// TODO: Ignore Ctrl key because it intercepts touchpad pinch to zoom?
|
||||
// TODO: what if the user doesn't use a touchpad, and thus has
|
||||
// no way to scroll horizontally other than by dragging a scrollbar?
|
||||
const ignoreCtrlWheel = false;
|
||||
|
||||
// Note: this hardcoded value feels good enough both for discrete mouse wheel and precise touchpad.
|
||||
const ZOOM_RAW_FACTOR = 100;
|
||||
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
options.axisVertical.zoom.raw.value -= event.deltaY / ZOOM_RAW_FACTOR;
|
||||
} else if (event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
options.axisHorizontal.zoom.raw.value -= event.deltaY / ZOOM_RAW_FACTOR;
|
||||
} else if (event.ctrlKey && !ignoreCtrlWheel) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
options.scrollOffsetLeft.value += event.deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
return useEventListener(target, "wheel", handler, {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
export interface WeakCache<K, V> {
|
||||
getOrNew(k: K): V;
|
||||
}
|
||||
|
||||
export function useWeakCache<K extends WeakKey, V>(
|
||||
factory: () => V,
|
||||
): WeakCache<K, V> {
|
||||
const cache = new WeakMap<K, V>();
|
||||
|
||||
return {
|
||||
getOrNew(k: K): V {
|
||||
if (cache.has(k)) {
|
||||
return cache.get(k)!;
|
||||
}
|
||||
const v = factory();
|
||||
cache.set(k, v);
|
||||
return v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// contentSizeIncludingEmptySpaceForZoom,
|
||||
contentSizeIncludingEmptySpace,
|
||||
axis,
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// contentSize,
|
||||
// contentSizeIncludingEmptySpaceForZoom,
|
||||
// contentSizeIncludingEmptySpace,
|
||||
// zoom: zoomWrapper,
|
||||
// };
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
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 });
|
||||
} else {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
const fpsLimit = 60
|
||||
const { pause, resume } = useRafFn(({ delta }) => {
|
||||
if (currentAudioTrack.value && trackStore.isPlaying) {
|
||||
const deltaSeconds = delta / 1000;
|
||||
timeline.advance(deltaSeconds);
|
||||
}
|
||||
}, { immediate: false, fpsLimit });
|
||||
|
||||
watch(() => trackStore.isPlaying, (isPlaying: boolean) => {
|
||||
if (isPlaying) {
|
||||
resume();
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === ' ') {
|
||||
if (!event.repeat) {
|
||||
trackStore.togglePlayPause();
|
||||
}
|
||||
// Prevent auto-repeated spacebar from triggering buttons
|
||||
event.preventDefault();
|
||||
}
|
||||
// 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,45 +84,33 @@ 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)">
|
||||
<div class="tw:flex-1">
|
||||
<TrackInfo v-if="false && currentAudioTrack" :track="currentAudioTrack" :edit="false" />
|
||||
|
||||
<!-- <TrackInfo v-if="false && currentAudioTrack" :track="currentAudioTrack" :edit="false" /> -->
|
||||
|
||||
<!-- TODO: debug data -->
|
||||
<div class="tw:flex tw:flex-col tw:items-center tw:p-8 tw:text-sm">
|
||||
<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>
|
||||
<p>Scroll Offset:
|
||||
{{ timeline.viewportScrollOffsetLeft.toFixed(2) }} x {{ timeline.viewportScrollOffsetTop.toFixed(2) }}
|
||||
</p>
|
||||
<p>Duration: {{ timeline.duration }}</p>
|
||||
<p>Viewport duration: {{ timeline.viewportDurationSeconds.toFixed(3) }}</p>
|
||||
<hr />
|
||||
<p>Zoom: {{ 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>including empty space: {{ timeline.contentWidthIncludingEmptySpace.toFixed(2) }}</p>
|
||||
<hr />
|
||||
<p>Viewport In/Out ...Seconds: {{ timeline.viewportInSeconds.toFixed(3) }} .. {{
|
||||
timeline.viewportOutSeconds.toFixed(3) }}</p>
|
||||
<p>... Loop Offset Beats: {{ timeline.viewportInLoopOffsetBeats.toFixed(3) }} .. {{
|
||||
timeline.viewportOutLoopOffsetBeats.toFixed(3) }}</p>
|
||||
<hr />
|
||||
<p>playheadPosition: {{ timeline.playheadPosition.toFixed(3) }} | {{
|
||||
timeline.playheadPositionLoopOffsetBeats.toFixed(3) }}</p>
|
||||
</div>
|
||||
|
||||
<PreviewScnene v-if="false" />
|
||||
<!-- TODO: debug data -->
|
||||
<div class="tw:flex tw:flex-col tw:items-center tw:p-8 tw:text-sm">
|
||||
<p>Viewport size:
|
||||
{{ timeline.viewportWidth.toFixed(2) }} x {{ timeline.viewportHeight.toFixed(2) }}
|
||||
</p>
|
||||
<p>Scroll Offset:
|
||||
{{ timeline.viewportScrollOffsetLeft.toFixed(2) }} x {{ timeline.viewportScrollOffsetTop.toFixed(2) }}
|
||||
</p>
|
||||
<p>Duration: {{ timeline.duration }}</p>
|
||||
<p>Viewport duration: {{ timeline.viewportDurationSeconds.toFixed(3) }}</p>
|
||||
<hr/>
|
||||
<p>Zoom: {{ timeline.viewportZoomHorizontal.toFixed(4) }} x {{ timeline.viewportZoomVertical.toFixed(4) }}</p>
|
||||
<p>Content size: {{ timeline.contentWidth }} x ({{ timeline.contentHeight }} = {{ timeline.trackHeight }}px x {{ timeline.visibleTracks.length }} tracks)</p>
|
||||
<p>including empty space: {{ timeline.contentWidthIncludingEmptySpace.toFixed(2) }}</p>
|
||||
<hr/>
|
||||
<p>Viewport In/Out ...Seconds: {{ timeline.viewportInSeconds.toFixed(3) }} .. {{ timeline.viewportOutSeconds.toFixed(3) }}</p>
|
||||
<p>... Loop Offset Beats: {{ timeline.viewportInLoopOffsetBeats.toFixed(3) }} .. {{ timeline.viewportOutLoopOffsetBeats.toFixed(3) }}</p>
|
||||
<hr/>
|
||||
<p>playheadPosition: {{ timeline.playheadPosition.toFixed(3) }}</p>
|
||||
</div>
|
||||
|
||||
<InspectorPanel />
|
||||
<PreviewScnene v-if="false" />
|
||||
</div>
|
||||
|
||||
<TimelinePanel class="tw:flex-1 tw:min-h-50" />
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,192 +1,134 @@
|
|||
@font-face {
|
||||
font-family: "3270";
|
||||
src: url("./assets/fonts/3270-Regular.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@import "./reset.css" layer(base);
|
||||
@import "tailwindcss" prefix(tw);
|
||||
@import "./reset.css";
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
--main-background-color: #28282e;
|
||||
--inactive-text-color: #909090;
|
||||
--active-text-color: #ffffff;
|
||||
* {
|
||||
--main-background-color: #28282e;
|
||||
--inactive-text-color: #909090;
|
||||
--active-text-color: #ffffff;
|
||||
|
||||
--view-separator-color: #090909;
|
||||
--view-separator-border: 1px solid var(--view-separator-color);
|
||||
--view-separator-color: #090909;
|
||||
--view-separator-border: 1px solid var(--view-separator-color);
|
||||
|
||||
--header-background-color: #17181a;
|
||||
--toolbar-background-color: #212126;
|
||||
--view-background-color: #212126;
|
||||
--card-background-color: #2a2a2d;
|
||||
--card-border-color: #000000;
|
||||
--card-border-width: 1px;
|
||||
--card-border-radius: 4px;
|
||||
--card-border: var(--card-border-width) solid var(--card-border-color);
|
||||
--card-separator-color: #212126;
|
||||
--card-separator-width: 2px;
|
||||
--card-outline-color: #929292;
|
||||
--card-outline-selected-color: #fa5b4a;
|
||||
--card-min-width: 24rem;
|
||||
--header-background-color: #17181a;
|
||||
--toolbar-background-color: #212126;
|
||||
--view-background-color: #212126;
|
||||
--card-background-color: #2a2a2d;
|
||||
--card-border-color: #000000;
|
||||
--card-border-width: 1px;
|
||||
--card-border-radius: 4px;
|
||||
--card-border: var(--card-border-width) solid var(--card-border-color);
|
||||
--card-separator-color: #212126;
|
||||
--card-separator-width: 2px;
|
||||
--card-outline-color: #929292;
|
||||
--card-outline-selected-color: #fa5b4a;
|
||||
--card-min-width: 24rem;
|
||||
|
||||
--input-background-color: #1f1f1f;
|
||||
--input-outline-color: #070707;
|
||||
/* disabled */
|
||||
--input-disabled-text-color: #525256;
|
||||
--input-disabled-background-color: #242428;
|
||||
--input-disabled-outline-color: #1a1a1d;
|
||||
/* selected */
|
||||
--input-selected-outline-color: #e64b3d;
|
||||
--input-border-width: 1px;
|
||||
--input-border-radius: 4px;
|
||||
--input-selection-color: #4b4b4b;
|
||||
--input-background-color: #1f1f1f;
|
||||
--input-outline-color: #070707;
|
||||
--input-outline-selected-color: #e64b3d;
|
||||
--input-border-width: 1px;
|
||||
--input-border-radius: 4px;
|
||||
|
||||
--timeline-background-color: var(--main-background-color);
|
||||
--timeline-background-top-color: #18181e;
|
||||
--timeline-border-top-color: var(--view-separator-color);
|
||||
--timeline-header-separator-color: #000000;
|
||||
--timeline-header-tick-edge-color: #2f3036;
|
||||
/*
|
||||
track layout:
|
||||
border-top
|
||||
...track content...
|
||||
border-bottom
|
||||
--- border (separator) ---
|
||||
border-top
|
||||
...track content...
|
||||
border-bottom
|
||||
*/
|
||||
--timeline-track-border-color: #00000080;
|
||||
--timeline-track-border: 1px solid var(--timeline-track-border-color);
|
||||
--timeline-track-border-top-color: #00000033;
|
||||
--timeline-track-border-top: 1px solid var(--timeline-track-border-top-color);
|
||||
--timeline-track-border-bottom-color: #0000003a;
|
||||
--timeline-track-border-bottom: 1px solid var(--timeline-track-border-bottom-color);
|
||||
--timeline-text-color: #909090;
|
||||
--timeline-bar-color: #fffff0;
|
||||
--timeline-bar-opacity: 11%;
|
||||
--timeline-bar-width: 1px;
|
||||
--timeline-background-color: var(--main-background-color);
|
||||
--timeline-background-top-color: #18181e;
|
||||
--timeline-border-top-color: var(--view-separator-color);
|
||||
--timeline-header-separator-color: #000000;
|
||||
--timeline-header-tick-edge-color: #2f3036;
|
||||
/*
|
||||
track layout:
|
||||
border-top
|
||||
...track content...
|
||||
border-bottom
|
||||
--- border (separator) ---
|
||||
border-top
|
||||
...track content...
|
||||
border-bottom
|
||||
*/
|
||||
--timeline-track-border-color: #00000080;
|
||||
--timeline-track-border: 1px solid var(--timeline-track-border-color);
|
||||
--timeline-track-border-top-color: #00000033;
|
||||
--timeline-track-border-top: 1px solid var(--timeline-track-border-top-color);
|
||||
--timeline-track-border-bottom-color: #0000003a;
|
||||
--timeline-track-border-bottom: 1px solid var(--timeline-track-border-bottom-color);
|
||||
--timeline-text-color: #909090;
|
||||
--timeline-bar-color: #fffff0;
|
||||
--timeline-bar-opacity: 11%;
|
||||
--timeline-bar-width: 1px;
|
||||
|
||||
--timeline-playhead-color: #e64b3d;
|
||||
/* 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-playhead-color: #e64b3d;
|
||||
|
||||
--timeline-marker-beat-color: #ffffff1c;
|
||||
--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;
|
||||
|
||||
--timeline-clip-border-color: #15151580;
|
||||
--timeline-clip-border-color-inner: #151515;
|
||||
--timeline-clip-border-radius: 4px;
|
||||
/*
|
||||
* TODO:
|
||||
* timeline clip selected outline:
|
||||
* inner 1px black
|
||||
* outer 2px red
|
||||
*/
|
||||
--timeline-clip-outline-selected-color: #e64b3d;
|
||||
--timeline-clip-outline-selected-width: 2px;
|
||||
--timeline-clip-outline-selected: var(--timeline-clip-outline-selected-width) solid var(--timeline-clip-outline-selected-color);
|
||||
|
||||
/*
|
||||
* TODO:
|
||||
* timeline clip selected outline:
|
||||
* inner 1px black
|
||||
* outer 2px red
|
||||
*/
|
||||
--timeline-clip-outline-selected-color: #e64b3d;
|
||||
--timeline-clip-outline-selected-width: 2px;
|
||||
--timeline-clip-outline-selected: var(--timeline-clip-outline-selected-width) solid var(--timeline-clip-outline-selected-color);
|
||||
--timeline-clip-color-orange: #eb6e01;
|
||||
--timeline-clip-color-apricot: #ffa833;
|
||||
--timeline-clip-color-yellow: #d4ad1f;
|
||||
--timeline-clip-color-lime: #9fc615;
|
||||
--timeline-clip-color-olive: #5f9921;
|
||||
--timeline-clip-color-green: #448f65;
|
||||
--timeline-clip-color-teal: #019899;
|
||||
--timeline-clip-color-navy: #005278;
|
||||
--timeline-clip-color-blue: #4376a1;
|
||||
--timeline-clip-color-purple: #9972a0;
|
||||
--timeline-clip-color-violet: #d0568d;
|
||||
--timeline-clip-color-pink: #e98cb5;
|
||||
--timeline-clip-color-tan: #b9af97;
|
||||
--timeline-clip-color-beige: #c4a07c;
|
||||
--timeline-clip-color-brown: #996601;
|
||||
--timeline-clip-color-chocolate: #8c5a3f;
|
||||
|
||||
/* See ./lib/colors/clips.ts */
|
||||
--timeline-clip-color-orange: #eb6e01;
|
||||
--timeline-clip-color-apricot: #ffa833;
|
||||
--timeline-clip-color-yellow: #d4ad1f;
|
||||
--timeline-clip-color-lime: #9fc615;
|
||||
--timeline-clip-color-olive: #5f9921;
|
||||
--timeline-clip-color-green: #448f65;
|
||||
--timeline-clip-color-teal: #019899;
|
||||
--timeline-clip-color-navy: #005278;
|
||||
--timeline-clip-color-blue: #4376a1;
|
||||
--timeline-clip-color-purple: #9972a0;
|
||||
--timeline-clip-color-violet: #d0568d;
|
||||
--timeline-clip-color-pink: #e98cb5;
|
||||
--timeline-clip-color-tan: #b9af97;
|
||||
--timeline-clip-color-beige: #c4a07c;
|
||||
--timeline-clip-color-brown: #996601;
|
||||
--timeline-clip-color-chocolate: #8c5a3f;
|
||||
--timeline-clip-label-background-color: #00000099;
|
||||
--timeline-clip-label-border-color: #00000060;
|
||||
|
||||
--timeline-clip-label-background-color: #00000099;
|
||||
--timeline-clip-label-border-color: #00000060;
|
||||
|
||||
--timeline-clip-baseline-color: #00000033;
|
||||
|
||||
--player-background-color: #1a1a1a;
|
||||
|
||||
--inspector-section-separator-color: #1e1e1e;
|
||||
|
||||
--control-button-text-color: #929292;
|
||||
--control-button-background-color: var(--main-background-color);
|
||||
--control-button-border-color: #43474d;
|
||||
/* active */
|
||||
--control-button-active-text-color: #ffffff;
|
||||
--control-button-active-background-color: #17181a;
|
||||
--control-button-active-border-color: #ffffff;
|
||||
/* disabled */
|
||||
--control-button-disabled-text-color: #525256;
|
||||
--control-button-disabled-border-color: #33343b;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: var(--main-background-color);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
--timeline-clip-baseline-color: #00000033;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-3270: "3270", monospace;
|
||||
--font-mono: "3270", monospace;
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: var(--main-background-color);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
|
@ -201,11 +143,7 @@
|
|||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar-icon-shadow {
|
||||
filter: drop-shadow(rgb(0 0 0 / 0.75) 0px 1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card-border {
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import base from "./vite.config";
|
||||
import { mergeConfig } from "vite";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
|
||||
export default mergeConfig(base, {
|
||||
define: {
|
||||
// See https://github.com/vitest-dev/vitest/issues/6872
|
||||
"process.env": JSON.stringify({}),
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
// environment: 'jsdom',
|
||||
// include tests in `tests/` directory
|
||||
include: [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.test.ts",
|
||||
],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
// https://vitest.dev/config/browser/playwright
|
||||
instances: [
|
||||
{ browser: "firefox" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -992,8 +992,8 @@ namespace MuzikaGromche
|
|||
public readonly record struct Easing(string Name, Func<float, float> Eval)
|
||||
{
|
||||
public 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));
|
||||
|
|
|
|||