preview speed control
This commit is contained in:
+50
-2
@@ -18,6 +18,7 @@
|
||||
selectAll,
|
||||
setActiveSwitch,
|
||||
setFps,
|
||||
setPreviewSpeed,
|
||||
setVideoUrl,
|
||||
snapToFrame,
|
||||
updateMarker,
|
||||
@@ -38,7 +39,12 @@
|
||||
|
||||
function rescheduleAudio() {
|
||||
if (!videoEl) return;
|
||||
audioEngine.reschedule(videoEl.currentTime, app.markers, !videoEl.paused);
|
||||
audioEngine.reschedule(
|
||||
videoEl.currentTime,
|
||||
app.markers,
|
||||
!videoEl.paused,
|
||||
app.previewSpeed,
|
||||
);
|
||||
lastScheduledTime = videoEl.currentTime;
|
||||
}
|
||||
|
||||
@@ -60,11 +66,18 @@
|
||||
const playing = app.isPlaying;
|
||||
void app.markers;
|
||||
void app.activeSwitch;
|
||||
void app.previewSpeed;
|
||||
if (playing && Math.abs(t - lastScheduledTime) > 0.25) {
|
||||
rescheduleAudio();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const speed = app.previewSpeed;
|
||||
controller?.setPlaybackRate(speed);
|
||||
if (app.isPlaying) rescheduleAudio();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
@@ -254,6 +267,22 @@
|
||||
</button>
|
||||
<span class="shortcut-hint">Ctrl+Space</span>
|
||||
|
||||
<label class="speed-control">
|
||||
Preview
|
||||
<input
|
||||
type="range"
|
||||
min="25"
|
||||
max="200"
|
||||
step="5"
|
||||
value={Math.round(app.previewSpeed * 100)}
|
||||
disabled={!app.videoUrl}
|
||||
oninput={(event) => {
|
||||
setPreviewSpeed(Number(event.currentTarget.value) / 100);
|
||||
}}
|
||||
/>
|
||||
<span class="speed-value">{Math.round(app.previewSpeed * 100)}%</span>
|
||||
</label>
|
||||
|
||||
<label class="switch-select">
|
||||
Switch
|
||||
<select
|
||||
@@ -378,8 +407,27 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.switch-select {
|
||||
.speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.speed-control input[type='range'] {
|
||||
width: 8rem;
|
||||
accent-color: #68d391;
|
||||
}
|
||||
|
||||
.speed-value {
|
||||
min-width: 2.75rem;
|
||||
text-align: right;
|
||||
color: #a0aec0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.switch-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -11,7 +11,12 @@ import { app } from '../store.svelte';
|
||||
|
||||
export type AudioEngine = {
|
||||
init: (video: HTMLVideoElement) => Promise<void>;
|
||||
reschedule: (currentTime: number, markers: Marker[], isPlaying: boolean) => void;
|
||||
reschedule: (
|
||||
currentTime: number,
|
||||
markers: Marker[],
|
||||
isPlaying: boolean,
|
||||
playbackRate: number,
|
||||
) => void;
|
||||
playKey: (code: string, keyLabel: string, phase: SoundPhase) => void;
|
||||
preloadActiveSwitch: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
@@ -146,7 +151,7 @@ export function createAudioEngine(): AudioEngine {
|
||||
await preloadSwitch(app.activeSwitch);
|
||||
},
|
||||
|
||||
reschedule(time: number, markers: Marker[], playing: boolean) {
|
||||
reschedule(time: number, markers: Marker[], playing: boolean, playbackRate: number) {
|
||||
if (!ctx || !playing) {
|
||||
clearScheduled();
|
||||
return;
|
||||
@@ -155,6 +160,7 @@ export function createAudioEngine(): AudioEngine {
|
||||
clearScheduled();
|
||||
const audioCtx = ctx;
|
||||
const startAt = audioCtx.currentTime + 0.02;
|
||||
const rate = playbackRate > 0 ? playbackRate : 1;
|
||||
const switchType = app.activeSwitch;
|
||||
|
||||
void (async () => {
|
||||
@@ -172,14 +178,14 @@ export function createAudioEngine(): AudioEngine {
|
||||
const releaseTime = markerReleaseTime(marker);
|
||||
|
||||
if (marker.time >= time - 0.05) {
|
||||
const pressDelay = marker.time - time;
|
||||
const pressDelay = (marker.time - time) / rate;
|
||||
const pressBuffer = await loadBuffer(switchType, 'press', pressKey);
|
||||
const pressSource = playBuffer(pressBuffer, startAt + Math.max(0, pressDelay));
|
||||
if (pressSource) scheduledSources.push(pressSource);
|
||||
}
|
||||
|
||||
if (releaseTime >= time - 0.05) {
|
||||
const releaseDelay = releaseTime - time;
|
||||
const releaseDelay = (releaseTime - time) / rate;
|
||||
const releaseBuffer = await loadBuffer(switchType, 'release', releaseKey);
|
||||
const releaseSource = playBuffer(releaseBuffer, startAt + Math.max(0, releaseDelay));
|
||||
if (releaseSource) scheduledSources.push(releaseSource);
|
||||
|
||||
@@ -14,6 +14,7 @@ export const app = $state({
|
||||
videoUrl: null as string | null,
|
||||
videoName: null as string | null,
|
||||
timelineWidth: 800,
|
||||
previewSpeed: 1,
|
||||
});
|
||||
|
||||
let markerCounter = 0;
|
||||
@@ -52,6 +53,10 @@ export function setFps(value: number) {
|
||||
app.fps = value;
|
||||
}
|
||||
|
||||
export function setPreviewSpeed(value: number) {
|
||||
app.previewSpeed = Math.max(0.25, Math.min(2, value));
|
||||
}
|
||||
|
||||
export function setPixelsPerSecond(value: number) {
|
||||
app.pixelsPerSecond = value;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type VideoController = {
|
||||
togglePlayPause: () => void;
|
||||
stepFrame: (delta: number) => void;
|
||||
seek: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
};
|
||||
|
||||
export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
||||
@@ -71,6 +72,9 @@ export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
||||
|
||||
if (video.readyState >= 1) onLoadedMetadata();
|
||||
|
||||
video.preservesPitch = true;
|
||||
video.playbackRate = app.previewSpeed;
|
||||
|
||||
return {
|
||||
element: video,
|
||||
destroy() {
|
||||
@@ -99,5 +103,8 @@ export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
||||
video.currentTime = clamped;
|
||||
setCurrentTime(clamped);
|
||||
},
|
||||
setPlaybackRate(rate: number) {
|
||||
video.playbackRate = rate;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user