441 lines
9.6 KiB
TypeScript
441 lines
9.6 KiB
TypeScript
import { MediaSet } from './generated/media_set';
|
|
import { Observable } from 'rxjs';
|
|
import { SelectionChangeEvent } from './HudCanvas';
|
|
import { SelectionMode, CanvasLogicalWidth } from './HudCanvasState';
|
|
import { PlayState } from './Player';
|
|
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
|
|
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
|
|
|
|
export const zoomFactor = 2;
|
|
|
|
const initialViewportCanvasPixels = 100;
|
|
|
|
export interface FrameRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
// TODO: rename to x1, x2
|
|
interface CanvasRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
interface Position {
|
|
currentTime: number;
|
|
frame: number;
|
|
percent: number;
|
|
}
|
|
|
|
export interface State {
|
|
mediaSet?: MediaSet;
|
|
selection: FrameRange;
|
|
viewport: FrameRange;
|
|
overviewPeaks: Observable<number[]>;
|
|
waveformPeaks: Observable<number[]>;
|
|
// selection canvas. Not kept up-to-date, only used for pushing updates.
|
|
selectionCanvas: CanvasRange;
|
|
// viewport canvas. Not kept up-to-date, only used for pushing updates.
|
|
viewportCanvas: CanvasRange;
|
|
audioSrc: string;
|
|
videoSrc: string;
|
|
position: Position;
|
|
// playback position in seconds, only used for forcing a change of position.
|
|
currentTime?: number;
|
|
playState: PlayState;
|
|
}
|
|
|
|
interface MediaSetLoadedAction {
|
|
type: 'mediasetloaded';
|
|
mediaSet: MediaSet;
|
|
}
|
|
|
|
interface OverviewPeaksLoadedAction {
|
|
type: 'overviewpeaksloaded';
|
|
peaks: Observable<number[]>;
|
|
}
|
|
|
|
interface WaveformPeaksLoadedAction {
|
|
type: 'waveformpeaksloaded';
|
|
peaks: Observable<number[]>;
|
|
}
|
|
|
|
interface AudioSourceLoadedAction {
|
|
type: 'audiosourceloaded';
|
|
numFrames: number;
|
|
src: string;
|
|
}
|
|
|
|
interface VideoSourceLoadedAction {
|
|
type: 'videosourceloaded';
|
|
src: string;
|
|
}
|
|
|
|
interface SetViewportAction {
|
|
type: 'setviewport';
|
|
viewport: FrameRange;
|
|
}
|
|
|
|
interface ZoomInAction {
|
|
type: 'zoomin';
|
|
}
|
|
|
|
interface ZoomOutAction {
|
|
type: 'zoomout';
|
|
}
|
|
|
|
interface ViewportChangedAction {
|
|
type: 'viewportchanged';
|
|
event: SelectionChangeEvent;
|
|
}
|
|
|
|
interface WaveformSelectionChangedAction {
|
|
type: 'waveformselectionchanged';
|
|
event: SelectionChangeEvent;
|
|
}
|
|
|
|
interface PositionChangedAction {
|
|
type: 'positionchanged';
|
|
currentTime: number;
|
|
}
|
|
|
|
interface SkipAction {
|
|
type: 'skip';
|
|
currentTime: number;
|
|
}
|
|
|
|
interface PlayAction {
|
|
type: 'play';
|
|
}
|
|
|
|
interface PauseAction {
|
|
type: 'pause';
|
|
}
|
|
|
|
type Action =
|
|
| MediaSetLoadedAction
|
|
| OverviewPeaksLoadedAction
|
|
| WaveformPeaksLoadedAction
|
|
| AudioSourceLoadedAction
|
|
| VideoSourceLoadedAction
|
|
| SetViewportAction
|
|
| ZoomInAction
|
|
| ZoomOutAction
|
|
| ViewportChangedAction
|
|
| WaveformSelectionChangedAction
|
|
| PositionChangedAction
|
|
| SkipAction
|
|
| PlayAction
|
|
| PauseAction;
|
|
|
|
export const stateReducer = (state: State, action: Action): State => {
|
|
switch (action.type) {
|
|
case 'mediasetloaded':
|
|
return handleMediaSetLoaded(state, action);
|
|
case 'overviewpeaksloaded':
|
|
return handleOverviewPeaksLoaded(state, action);
|
|
case 'waveformpeaksloaded':
|
|
return handleWaveformPeaksLoaded(state, action);
|
|
case 'audiosourceloaded':
|
|
return handleAudioSourceLoaded(state, action);
|
|
case 'videosourceloaded':
|
|
return handleVideoSourceLoaded(state, action);
|
|
case 'setviewport':
|
|
return setViewport(state, action);
|
|
case 'zoomin':
|
|
return handleZoomIn(state);
|
|
case 'zoomout':
|
|
return handleZoomOut(state);
|
|
case 'viewportchanged':
|
|
return handleViewportChanged(state, action);
|
|
case 'waveformselectionchanged':
|
|
return handleWaveformSelectionChanged(state, action);
|
|
case 'positionchanged':
|
|
return handlePositionChanged(state, action);
|
|
case 'skip':
|
|
return skip(state, action);
|
|
case 'play':
|
|
return play(state);
|
|
case 'pause':
|
|
return pause(state);
|
|
}
|
|
};
|
|
|
|
function handleMediaSetLoaded(
|
|
state: State,
|
|
{ mediaSet }: MediaSetLoadedAction
|
|
): State {
|
|
const numFrames = Math.min(
|
|
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
|
|
initialViewportCanvasPixels,
|
|
mediaSet.audioFrames
|
|
);
|
|
|
|
return setViewport(
|
|
{ ...state, mediaSet },
|
|
{ type: 'setviewport', viewport: { start: 0, end: numFrames } }
|
|
);
|
|
}
|
|
|
|
function handleOverviewPeaksLoaded(
|
|
state: State,
|
|
{ peaks }: OverviewPeaksLoadedAction
|
|
) {
|
|
return { ...state, overviewPeaks: peaks };
|
|
}
|
|
|
|
function handleWaveformPeaksLoaded(
|
|
state: State,
|
|
{ peaks }: WaveformPeaksLoadedAction
|
|
) {
|
|
return { ...state, waveformPeaks: peaks };
|
|
}
|
|
|
|
function handleAudioSourceLoaded(
|
|
state: State,
|
|
{ src, numFrames }: AudioSourceLoadedAction
|
|
): State {
|
|
const mediaSet = state.mediaSet;
|
|
if (mediaSet == null) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
audioSrc: src,
|
|
mediaSet: { ...mediaSet, audioFrames: numFrames },
|
|
};
|
|
}
|
|
|
|
function handleVideoSourceLoaded(
|
|
state: State,
|
|
{ src }: VideoSourceLoadedAction
|
|
): State {
|
|
return { ...state, videoSrc: src };
|
|
}
|
|
|
|
function setViewport(state: State, { viewport }: SetViewportAction): State {
|
|
const { mediaSet, selection } = state;
|
|
if (!mediaSet) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
viewport: viewport,
|
|
viewportCanvas: {
|
|
start: Math.round(
|
|
(viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth
|
|
),
|
|
end: Math.round(
|
|
(viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth
|
|
),
|
|
},
|
|
selectionCanvas: selectionToWaveformCanvasRange(selection, viewport),
|
|
};
|
|
}
|
|
|
|
function handleZoomIn(state: State): State {
|
|
const {
|
|
mediaSet,
|
|
viewport,
|
|
selection,
|
|
position: { frame },
|
|
} = state;
|
|
|
|
if (!mediaSet) {
|
|
return state;
|
|
}
|
|
|
|
const newViewport = zoomViewportIn(
|
|
viewport,
|
|
mediaSet.audioFrames,
|
|
selection,
|
|
frame,
|
|
zoomFactor
|
|
);
|
|
|
|
// TODO: refactoring zoom helpers to use CanvasRange may avoid this step:
|
|
return setViewport(state, { type: 'setviewport', viewport: newViewport });
|
|
}
|
|
|
|
function handleZoomOut(state: State): State {
|
|
const {
|
|
mediaSet,
|
|
viewport,
|
|
selection,
|
|
position: { currentTime },
|
|
} = state;
|
|
|
|
if (!mediaSet) {
|
|
return state;
|
|
}
|
|
|
|
const newViewport = zoomViewportOut(
|
|
viewport,
|
|
mediaSet.audioFrames,
|
|
selection,
|
|
currentTime,
|
|
zoomFactor
|
|
);
|
|
|
|
// TODO: refactoring zoom helpers to use CanvasRange may avoid this step:
|
|
return setViewport(state, { type: 'setviewport', viewport: newViewport });
|
|
}
|
|
|
|
function handleViewportChanged(
|
|
state: State,
|
|
{ event: { mode, selection: canvasRange } }: ViewportChangedAction
|
|
): State {
|
|
const { mediaSet, selection } = state;
|
|
if (!mediaSet) {
|
|
return state;
|
|
}
|
|
|
|
if (mode != SelectionMode.Normal) {
|
|
return state;
|
|
}
|
|
|
|
const newViewport = {
|
|
start: Math.round(
|
|
mediaSet.audioFrames * (canvasRange.start / CanvasLogicalWidth)
|
|
),
|
|
end: Math.round(
|
|
mediaSet.audioFrames * (canvasRange.end / CanvasLogicalWidth)
|
|
),
|
|
};
|
|
|
|
return {
|
|
...state,
|
|
viewport: newViewport,
|
|
selectionCanvas: selectionToWaveformCanvasRange(selection, newViewport),
|
|
};
|
|
}
|
|
|
|
function handleWaveformSelectionChanged(
|
|
state: State,
|
|
{
|
|
event: { mode, prevMode, selection: canvasRange },
|
|
}: WaveformSelectionChangedAction
|
|
): State {
|
|
const {
|
|
mediaSet,
|
|
playState,
|
|
viewport,
|
|
position: { frame: currFrame },
|
|
} = state;
|
|
|
|
if (mediaSet == null) {
|
|
return state;
|
|
}
|
|
|
|
const framesPerPixel = (viewport.end - viewport.start) / CanvasLogicalWidth;
|
|
const newSelection = {
|
|
start: Math.round(viewport.start + canvasRange.start * framesPerPixel),
|
|
end: Math.round(viewport.start + canvasRange.end * framesPerPixel),
|
|
};
|
|
|
|
let currentTime = state.currentTime;
|
|
if (
|
|
prevMode != SelectionMode.Normal &&
|
|
mode == SelectionMode.Normal &&
|
|
(playState == PlayState.Paused ||
|
|
currFrame < newSelection.start ||
|
|
currFrame > newSelection.end)
|
|
) {
|
|
currentTime = newSelection.start / mediaSet.audioSampleRate;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
selection: newSelection,
|
|
currentTime: currentTime,
|
|
};
|
|
}
|
|
|
|
function handlePositionChanged(
|
|
state: State,
|
|
{ currentTime }: PositionChangedAction
|
|
): State {
|
|
const {
|
|
mediaSet,
|
|
selection,
|
|
position: { frame: prevFrame },
|
|
} = state;
|
|
if (mediaSet == null) {
|
|
return state;
|
|
}
|
|
|
|
const frame = Math.round(currentTime * mediaSet.audioSampleRate);
|
|
const percent = (frame / mediaSet.audioFrames) * 100;
|
|
|
|
// reset play position and pause if selection end passed.
|
|
let playState = state.playState;
|
|
let forceCurrentTime;
|
|
if (
|
|
selection.start != selection.end &&
|
|
prevFrame < selection.end &&
|
|
frame >= selection.end
|
|
) {
|
|
playState = PlayState.Paused;
|
|
forceCurrentTime = selection.start / mediaSet.audioSampleRate;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
playState,
|
|
currentTime: forceCurrentTime,
|
|
position: {
|
|
currentTime,
|
|
frame,
|
|
percent,
|
|
},
|
|
};
|
|
}
|
|
|
|
function skip(state: State, { currentTime }: SkipAction): State {
|
|
return { ...state, currentTime: currentTime };
|
|
}
|
|
|
|
function play(state: State): State {
|
|
return { ...state, playState: PlayState.Playing };
|
|
}
|
|
|
|
function pause(state: State): State {
|
|
const { mediaSet, selection } = state;
|
|
if (!mediaSet) {
|
|
return state;
|
|
}
|
|
|
|
let currentTime;
|
|
if (selection.start != selection.end) {
|
|
currentTime = selection.start / mediaSet.audioSampleRate;
|
|
}
|
|
|
|
return { ...state, currentTime, playState: PlayState.Paused };
|
|
}
|
|
|
|
// helpers
|
|
|
|
function selectionToWaveformCanvasRange(
|
|
selection: FrameRange,
|
|
viewport: FrameRange
|
|
): CanvasRange {
|
|
const x1 = frameToWaveformCanvasX(
|
|
selection.start,
|
|
viewport,
|
|
CanvasLogicalWidth
|
|
);
|
|
const x2 = frameToWaveformCanvasX(
|
|
selection.end,
|
|
viewport,
|
|
CanvasLogicalWidth
|
|
);
|
|
|
|
if (x1 == x2) {
|
|
return { start: 0, end: 0 };
|
|
}
|
|
|
|
return { start: x1 || 0, end: x2 || CanvasLogicalWidth };
|
|
}
|