A pattern for one song had to be made in the Assets panel first and dragged in, and then stood in every picker of the project. Now a right-click on an empty spot of a track makes one the song keeps to itself, on top of the embedded assets of the first part. - Create pattern lays an item from the grid line before the spot, a bar long, and opens the pattern in its tab. Pattern, item and name are one undo step. The name is "Pattern 1", 2, … per song. - It plays the instrument of the track's pattern item that starts last before the spot, else of the first one after it. An item's own pick counts before its pattern's. - An embedded pattern runs on the song's clock. It takes the tempo and the meter of the song, and a change on the song moves it along in the same undo step (`AssetPropertyCommand`). - Its rows Tempo, Beats per bar, Beat and Bus are hidden, as the song decides them. Length, grid, instrument and sound FX stay. - A copy that comes to a song (Embed a copy, a paste from another song) takes that song's clock. Made a project asset, it keeps the values it had. - `CreateAssetCommand` takes an owner. `embeddedCopy` takes the song now, not its id. - The cleanup of e2e 65 also takes what the song keeps. Tests: - Unit embedded: 18 green (5 new), 6 counter-checks each red - Unit take, song, pattern and the asset commands: green - e2e 65 (melektaus-atx): ALL PASSED, 49 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4172 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1618 lines
60 KiB
TypeScript
1618 lines
60 KiB
TypeScript
import {
|
||
Container,
|
||
type FederatedPointerEvent,
|
||
type FederatedWheelEvent,
|
||
Graphics,
|
||
type Text,
|
||
} from 'pixi.js';
|
||
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||
import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands';
|
||
import {
|
||
embeddedCopy,
|
||
embeddedPatternName,
|
||
embeddedUsedBy,
|
||
instrumentNear,
|
||
isEmbedded,
|
||
listedAssets,
|
||
songClockProps,
|
||
} from '../../core/music/embedded';
|
||
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
||
import type { LoopRange } from '../../core/music/sequence';
|
||
import {
|
||
itemEnd,
|
||
newItem,
|
||
newTrack,
|
||
type SongItem,
|
||
type SongItemKind,
|
||
songGrid,
|
||
songLength,
|
||
songLoop,
|
||
songMeter,
|
||
songTempo,
|
||
type SongTrack,
|
||
TRACK_MAX_DB,
|
||
TRACK_MIN_DB,
|
||
} from '../../core/music/song';
|
||
import { ticksOfSeconds, ticksPerBar } from '../../core/music/time';
|
||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||
import {
|
||
activeDrag,
|
||
crispText,
|
||
drawBand,
|
||
endDrag,
|
||
Menu,
|
||
onDragChange,
|
||
pressCount,
|
||
type Size,
|
||
textStyle,
|
||
Theme,
|
||
ui,
|
||
Widget,
|
||
zoomFactor,
|
||
} from '../../ui';
|
||
import { playedSoundById, playedSoundOf } from '../../core/properties/playedSound';
|
||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||
import { TimeRuler } from '../music/TimeRuler';
|
||
import {
|
||
clampTimePan,
|
||
draggedLoop,
|
||
fitTime,
|
||
LOOP_STRIP,
|
||
RULER_HEIGHT,
|
||
rulerPartAt,
|
||
tickToX,
|
||
timeLines,
|
||
xToTick,
|
||
zoomTime,
|
||
} from '../music/timeView';
|
||
import { snapDown, snapNear } from '../pattern/rollEdit';
|
||
import type { EditorState } from '../state';
|
||
import { removeTrack } from './songActions';
|
||
import {
|
||
clampMove,
|
||
draggedLength,
|
||
droppedLength,
|
||
duplicateOffset,
|
||
moveDelta,
|
||
moved,
|
||
placedCopies,
|
||
resized,
|
||
roundsOf,
|
||
split,
|
||
trimDelta,
|
||
trimmed,
|
||
} from './songEdit';
|
||
import {
|
||
type Box,
|
||
clampTracks,
|
||
HEAD_BOXES,
|
||
HEAD_WIDTH,
|
||
EDGE_REACH,
|
||
type HeadPart,
|
||
headPartAt,
|
||
heldSongView,
|
||
indexOf,
|
||
itemAt,
|
||
itemBox,
|
||
type ItemPart,
|
||
itemsWithin,
|
||
laneTick,
|
||
loopSeams,
|
||
nearestTrack,
|
||
peakOver,
|
||
SLIDERS_MIN_HEIGHT,
|
||
sliderShare,
|
||
sliderValue,
|
||
type SongView,
|
||
TRACK_HEIGHT,
|
||
trackAtY,
|
||
trackTop,
|
||
zoomTracks,
|
||
} from './songView';
|
||
|
||
const DRAG_THRESHOLD = 3;
|
||
/** How close to an end of the loop a press on the ruler takes that end. */
|
||
const LOOP_EDGE_REACH = 4;
|
||
const PATTERN_COLOR = 0x35598f;
|
||
const SOUND_COLOR = 0x2f6f52;
|
||
const MISSING_COLOR = 0x6b3a3a;
|
||
const MUTE_COLOR = 0xd08a2c;
|
||
const TAKE_COLOR = 0xb33a3a;
|
||
/** The id the track of a take being recorded is drawn under, until it is made. */
|
||
const TAKE_TRACK = '__take';
|
||
const SOLO_COLOR = 0xc9c23a;
|
||
const FX_COLOR = 0x8fb8de;
|
||
/** Height of an item's name strip; what it plays is drawn below. */
|
||
const LABEL_STRIP = 15;
|
||
/** In front of the name of what the song keeps to itself (#355). */
|
||
export const EMBEDDED_MARK = '•';
|
||
/** The letter on a lit mute, solo or FX button. */
|
||
const DARK_LABEL = textStyle('small').clone();
|
||
DARK_LABEL.fill = 0x30343c;
|
||
|
||
interface Placed {
|
||
text: string;
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
/** Given, the text sits in the middle of the box `w` by `h`. */
|
||
h?: number;
|
||
dark?: boolean;
|
||
}
|
||
|
||
type Area = 'corner' | 'ruler' | 'heads' | 'lanes';
|
||
|
||
/**
|
||
* What a press with Shift did to the picked items: `added` the item, or found it picked – then a
|
||
* click that changes nothing `unpick`s it. A drag with Shift moves or sizes off the grid instead.
|
||
*/
|
||
type ShiftPress = 'added' | 'unpick' | null;
|
||
|
||
type Gesture =
|
||
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||
| {
|
||
kind: 'loop';
|
||
from: number;
|
||
pointerX: number;
|
||
active: boolean;
|
||
loop: LoopRange | null;
|
||
edge: 'start' | 'end' | null;
|
||
}
|
||
| { kind: 'cursor'; tick: number }
|
||
| { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean }
|
||
| {
|
||
kind: 'move';
|
||
anchor: SongItem;
|
||
grab: number;
|
||
ticks: number;
|
||
tracks: number;
|
||
moved: boolean;
|
||
pointer: Vec2;
|
||
shift: ShiftPress;
|
||
}
|
||
| { kind: 'resize'; anchor: SongItem; by: number; shift: ShiftPress }
|
||
| { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress }
|
||
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
|
||
|
||
/**
|
||
* A take being recorded (#342): the track it gets at `index`, where it starts, how far it got, and
|
||
* its waveform – a peak per `ticksPerPeak` ticks.
|
||
*/
|
||
export interface TakeView {
|
||
index: number;
|
||
tick: number;
|
||
length: number;
|
||
peaks: readonly number[];
|
||
ticksPerPeak: number;
|
||
uploading: boolean;
|
||
}
|
||
|
||
/** An asset dragged in from the panel, where it would land. */
|
||
interface Drop {
|
||
kind: SongItemKind;
|
||
asset: string;
|
||
track: string;
|
||
tick: number;
|
||
length: number;
|
||
}
|
||
|
||
/**
|
||
* The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the
|
||
* items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved
|
||
* by its body and sized by either end – pulled out past what it plays, it repeats that, and an end
|
||
* snaps to where a round starts as well as to the grid –, and goes with Delete or its menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
|
||
* selection, so the inspector shows it; a click on nothing picks the song. The wheel zooms: the time
|
||
* over the lanes, the tracks over their heads.
|
||
*/
|
||
export class SongTimeline extends Widget {
|
||
private readonly bg = new Graphics();
|
||
private readonly lanesLayer = new Container();
|
||
private readonly lanesG = new Graphics();
|
||
private readonly itemsG = new Graphics();
|
||
private readonly overlay = new Graphics();
|
||
private readonly itemLabels = new Container();
|
||
private readonly itemTexts: Text[] = [];
|
||
private readonly headsLayer = new Container();
|
||
private readonly headsG = new Graphics();
|
||
private readonly headLabels = new Container();
|
||
private readonly headTexts: Text[] = [];
|
||
private readonly ruler = new TimeRuler();
|
||
private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() };
|
||
private readonly hint = crispText({ text: '', style: textStyle('muted') });
|
||
private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0, trackHeight: TRACK_HEIGHT };
|
||
private needsFit = true;
|
||
private assetId = '';
|
||
private gesture: Gesture | null = null;
|
||
private drop: Drop | null = null;
|
||
/** The part of an item under the pointer while nothing is held. */
|
||
private hover: { id: string; part: ItemPart } | null = null;
|
||
/** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */
|
||
private pointer: Vec2 | null = null;
|
||
/** Where playing starts and a paste lands, in ticks. */
|
||
private startTick = 0;
|
||
/** What Ctrl+C took, for any song of the project. */
|
||
private clipboard: SongItem[] = [];
|
||
/** What the song kept to itself of the copied items (#355), as it was then: a paste still has it after a delete. */
|
||
private clipboardAssets: Asset[] = [];
|
||
/** Sounds asked for, so a waveform is drawn once its bytes are there. */
|
||
private readonly loading = new Set<string>();
|
||
/** Where the playhead stands while the song plays, in ticks; null while it does not. */
|
||
playhead: number | null = null;
|
||
/** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */
|
||
take: TakeView | null = null;
|
||
onTogglePlay: () => void = () => {};
|
||
/** Where playing starts was moved: a playing song jumps there. */
|
||
onStart: (tick: number) => void = () => {};
|
||
/** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */
|
||
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
|
||
/** An item was double-clicked, at a tick of the song. */
|
||
onOpenItem: (item: SongItem, tick: number) => void = () => {};
|
||
onDiscardTake: () => void = () => {};
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.focusable = true;
|
||
this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay);
|
||
this.headsLayer.addChild(this.headsG, this.headLabels);
|
||
this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint);
|
||
for (const clip of Object.values(this.clips)) this.addChild(clip);
|
||
this.lanesLayer.mask = this.clips.lanes;
|
||
this.headsLayer.mask = this.clips.heads;
|
||
this.ruler.mask = this.clips.ruler;
|
||
this.hint.eventMode = 'none';
|
||
this.itemLabels.eventMode = 'none';
|
||
this.headLabels.eventMode = 'none';
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||
const p = this.toLocal(e.global);
|
||
this.pointer = { x: p.x, y: p.y };
|
||
});
|
||
this.on('pointerleave', () => (this.pointer = null));
|
||
this.onPointerRelease(() => this.onPointerUp());
|
||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||
state.on('project', () => this.invalidate());
|
||
state.on('selection', () => this.invalidate());
|
||
// A drag can end anywhere, and this view never hears about it: the ghost would stay without this.
|
||
onDragChange(() => {
|
||
if (!activeDrag() && this.drop) {
|
||
this.drop = null;
|
||
this.invalidate();
|
||
}
|
||
});
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 360), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
get currentAssetId(): string {
|
||
return this.assetId;
|
||
}
|
||
|
||
/** Another song starts playing from its start, framed. */
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) return;
|
||
this.gesture = null;
|
||
this.drop = null;
|
||
this.assetId = assetId;
|
||
this.startTick = 0;
|
||
this.needsFit = true;
|
||
this.redraw();
|
||
}
|
||
|
||
saveView(): SongView {
|
||
return { ...this.view };
|
||
}
|
||
|
||
restoreView(view: unknown): void {
|
||
this.view = heldSongView(view as Partial<SongView>);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
}
|
||
|
||
/** The whole song across, from its first track down. */
|
||
fit(): void {
|
||
const asset = this.asset();
|
||
if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) {
|
||
this.needsFit = true;
|
||
return;
|
||
}
|
||
this.needsFit = false;
|
||
this.view = {
|
||
...fitTime(songLength(asset), this.lanesWidth),
|
||
panY: 0,
|
||
trackHeight: this.view.trackHeight,
|
||
};
|
||
this.redraw();
|
||
}
|
||
|
||
get start(): number {
|
||
return this.startTick;
|
||
}
|
||
|
||
/** The items picked, by id, when the selection is about this song. */
|
||
get pickedIds(): string[] {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'song' && s.assetId === this.assetId ? s.itemIds : [];
|
||
}
|
||
|
||
/** The track the selection is on, `''` when it is about something else. */
|
||
get pickedTrack(): string {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'song' && s.assetId === this.assetId ? s.trackId : '';
|
||
}
|
||
|
||
pick(itemIds: readonly string[], trackId?: string): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const last = asset.items.find((i) => i.id === itemIds.at(-1));
|
||
const track = trackId ?? last?.track ?? (this.pickedTrack || asset.tracks[0]?.id || '');
|
||
this.state.select({ kind: 'song', assetId: asset.id, trackId: track, itemIds: [...itemIds] });
|
||
}
|
||
|
||
// --- what the keys and the menus do ----------------------------------------------------------
|
||
|
||
selectAll(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
this.pick(asset.items.map((i) => i.id));
|
||
return true;
|
||
}
|
||
|
||
deleteSelection(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
this.commit(
|
||
asset,
|
||
{ items: asset.items.filter((i) => !picked.has(i.id)) },
|
||
picked.size === 1 ? 'Delete item' : 'Delete items',
|
||
);
|
||
this.pick([]);
|
||
return true;
|
||
}
|
||
|
||
copy(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i }));
|
||
this.clipboardAssets = structuredClone(embeddedUsedBy(this.state.project, this.clipboard));
|
||
const n = this.clipboard.length;
|
||
this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Lands under the pointer – the earliest item on the grid line before it, the topmost on its track –
|
||
* or, with the pointer off the lanes, at the start of playing on the tracks it came from. False with
|
||
* nothing copied.
|
||
*/
|
||
paste(): boolean {
|
||
const asset = this.asset();
|
||
if (!asset || this.clipboard.length === 0) return false;
|
||
const spot = this.pasteSpot(asset);
|
||
const copies = placedCopies(
|
||
this.clipboard,
|
||
spot?.tick ?? this.startTick,
|
||
asset.tracks.map((t) => t.id),
|
||
spot?.row,
|
||
);
|
||
this.commit(
|
||
asset,
|
||
{ items: [...asset.items, ...copies], spare: this.clipboardAssets },
|
||
copies.length === 1 ? 'Paste item' : 'Paste items',
|
||
);
|
||
this.pick(copies.map((i) => i.id));
|
||
return true;
|
||
}
|
||
|
||
/** A copy of the picked items right behind them, picked in their place. */
|
||
duplicate(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
const chosen = asset.items.filter((i) => picked.has(i.id));
|
||
const by = duplicateOffset(asset.items, picked, songGrid(asset.props));
|
||
const copies = placedCopies(
|
||
chosen,
|
||
Math.min(...chosen.map((i) => i.tick)) + by,
|
||
asset.tracks.map((t) => t.id),
|
||
);
|
||
this.commit(asset, { items: [...asset.items, ...copies] }, 'Duplicate items');
|
||
this.pick(copies.map((i) => i.id));
|
||
return true;
|
||
}
|
||
|
||
/** Cuts the picked items where playing starts; the right-hand parts are picked afterwards. */
|
||
splitAtStart(): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
const cut = split(asset.items, picked, this.startTick, songTempo(asset.props));
|
||
if (cut.right.length === 0) {
|
||
this.state.setNotice('Where playing starts is not inside a picked item');
|
||
return true;
|
||
}
|
||
this.commit(asset, { items: cut.items }, cut.right.length === 1 ? 'Split item' : 'Split items');
|
||
this.pick(cut.right);
|
||
return true;
|
||
}
|
||
|
||
/** Moves the picked items by ticks and tracks, as far as they can go. */
|
||
nudge(ticks: number, tracks: number): boolean {
|
||
const asset = this.asset();
|
||
const picked = new Set(this.pickedIds);
|
||
if (!asset || picked.size === 0) return false;
|
||
const by = clampMove(asset.items, picked, indexOf(asset.tracks), asset.tracks.length, ticks, tracks);
|
||
if (by.ticks !== 0 || by.tracks !== 0)
|
||
this.commit(
|
||
asset,
|
||
{
|
||
items: moved(
|
||
asset.items,
|
||
picked,
|
||
asset.tracks.map((t) => t.id),
|
||
by.ticks,
|
||
by.tracks,
|
||
),
|
||
},
|
||
picked.size === 1 ? 'Move item' : 'Move items',
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/** A new track under the others, or at `index`. */
|
||
addTrack(index?: number): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
const track = newTrack(asset.tracks.length + 1);
|
||
const tracks = [...asset.tracks];
|
||
tracks.splice(index ?? tracks.length, 0, track);
|
||
this.commit(asset, { tracks }, 'Add track');
|
||
this.pick([], track.id);
|
||
return true;
|
||
}
|
||
|
||
override onKeyDown(event: KeyboardEvent): boolean {
|
||
const asset = this.asset();
|
||
if (!asset) return false;
|
||
const mod = event.ctrlKey || event.metaKey;
|
||
const key = event.key.toLowerCase();
|
||
if (event.code === 'Space' && !mod) {
|
||
this.onTogglePlay();
|
||
return true;
|
||
}
|
||
if (this.take) {
|
||
if (event.key !== 'Escape') return false;
|
||
this.onDiscardTake();
|
||
return true;
|
||
}
|
||
if (mod && key === 'c') return this.copy();
|
||
if (mod && key === 'v') return this.paste();
|
||
if (mod && key === 'd') return this.duplicate() || true;
|
||
if (mod && key === 'e') return this.splitAtStart() || true;
|
||
if (mod && key === 'a') return this.selectAll();
|
||
if (mod) return false;
|
||
// Taken even with nothing picked: a Delete must not go on to ask about the song itself.
|
||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||
if (!this.deleteSelection() && this.pickedTrack) removeTrack(this.state, asset.id, this.pickedTrack);
|
||
return true;
|
||
}
|
||
if (event.key === 'Escape' && this.pickedIds.length > 0) {
|
||
this.pick([]);
|
||
return true;
|
||
}
|
||
const grid = songGrid(asset.props);
|
||
const bar = ticksPerBar(songMeter(asset.props));
|
||
if (event.key === 'ArrowUp') return this.nudge(0, -1);
|
||
if (event.key === 'ArrowDown') return this.nudge(0, 1);
|
||
if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0);
|
||
if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0);
|
||
return false;
|
||
}
|
||
|
||
// --- for the check scripts -------------------------------------------------------------------
|
||
|
||
/** The middle of a track's lane at a tick, on screen. */
|
||
lanePoint(tick: number, track: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return {
|
||
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
|
||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + this.view.trackHeight / 2,
|
||
};
|
||
}
|
||
|
||
/** A point of an item: the middle of its body or of one of its ends. */
|
||
itemPoint(itemId: string, part: 'start' | 'body' | 'end' = 'body'): Vec2 | null {
|
||
const asset = this.asset();
|
||
const item = asset?.items.find((i) => i.id === itemId);
|
||
const row = item && indexOf(asset!.tracks).get(item.track);
|
||
if (!item || row === undefined) return null;
|
||
const box = itemBox(this.view, row, item);
|
||
const at = this.getGlobalPosition();
|
||
const x = part === 'start' ? box.x + 2 : part === 'end' ? box.x + box.w - 2 : box.x + box.w / 2;
|
||
return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 };
|
||
}
|
||
|
||
/** The middle of a part of a track's head, or a share across its slider. */
|
||
headPoint(track: number, part: HeadPart, share = 0.5): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
const box = HEAD_BOXES[part];
|
||
const slider = part === 'volume' || part === 'pan';
|
||
return {
|
||
x: at.x + box.x + (slider ? box.w * share : box.w / 2),
|
||
y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2,
|
||
};
|
||
}
|
||
|
||
rulerPoint(tick: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 };
|
||
}
|
||
|
||
/** A tick on the strip of the ruler that holds the loop. */
|
||
loopPoint(tick: number): Vec2 {
|
||
const at = this.getGlobalPosition();
|
||
return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + LOOP_STRIP / 2 };
|
||
}
|
||
|
||
viewInfo(): SongView & {
|
||
start: number;
|
||
playhead: number | null;
|
||
loop: LoopRange | null;
|
||
drop: Drop | null;
|
||
hover: { id: string; part: ItemPart } | null;
|
||
cursor: string;
|
||
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
||
} {
|
||
return {
|
||
...this.view,
|
||
start: this.startTick,
|
||
playhead: this.playhead,
|
||
loop: this.shownLoop(),
|
||
drop: this.drop,
|
||
hover: this.hover,
|
||
cursor: String(this.cursor ?? ''),
|
||
take: this.take ? { ...this.take, peaks: this.take.peaks.length } : null,
|
||
};
|
||
}
|
||
|
||
/** The items as drawn: their name, what they show, their box on screen and where they start over. */
|
||
itemsDrawn(): {
|
||
id: string;
|
||
label: string;
|
||
content: 'notes' | 'wave' | 'none';
|
||
box: Box;
|
||
seams: number[];
|
||
}[] {
|
||
const asset = this.asset();
|
||
if (!asset) return [];
|
||
const rows = indexOf(asset.tracks);
|
||
const at = this.getGlobalPosition();
|
||
return this.shownItems(asset).flatMap((item) => {
|
||
const row = rows.get(item.track);
|
||
if (row === undefined) return [];
|
||
const box = itemBox(this.view, row, item);
|
||
return [
|
||
{
|
||
id: item.id,
|
||
label: this.labelOf(item),
|
||
content: this.contentOf(item),
|
||
box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + RULER_HEIGHT + box.y, w: box.w, h: box.h },
|
||
seams: this.seamsOf(item),
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
// --- pointer ---------------------------------------------------------------------------------
|
||
|
||
private get lanesWidth(): number {
|
||
return Math.max(0, this.w - HEAD_WIDTH);
|
||
}
|
||
|
||
private get lanesHeight(): number {
|
||
return Math.max(0, this.h - RULER_HEIGHT);
|
||
}
|
||
|
||
private asset(): SongAsset | null {
|
||
return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'song') ?? null) : null;
|
||
}
|
||
|
||
private areaAt(p: Vec2): Area {
|
||
const left = p.x < HEAD_WIDTH;
|
||
if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler';
|
||
return left ? 'heads' : 'lanes';
|
||
}
|
||
|
||
/** A point of the widget in the lanes' own pixels. */
|
||
private inLanes(p: Vec2): Vec2 {
|
||
return { x: p.x - HEAD_WIDTH, y: p.y - RULER_HEIGHT };
|
||
}
|
||
|
||
private onPointerDown(e: FederatedPointerEvent): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const local = this.toLocal(e.global);
|
||
const p = { x: local.x, y: local.y };
|
||
const area = this.areaAt(p);
|
||
e.stopPropagation();
|
||
ui().focus.focus(this);
|
||
if (e.button === 1) {
|
||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||
return;
|
||
}
|
||
if (this.take) return;
|
||
const g = this.inLanes(p);
|
||
const tick = xToTick(this.view, g.x);
|
||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
||
if (e.button === 2) {
|
||
if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global);
|
||
if (area === 'lanes') this.openItemMenu(asset, g, e.global);
|
||
this.invalidate();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
if (area === 'ruler' && rulerPartAt(p.y) === 'loop') {
|
||
const loop = songLoop(asset);
|
||
const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH;
|
||
const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null;
|
||
this.gesture = {
|
||
kind: 'loop',
|
||
from: Math.max(0, tick),
|
||
pointerX: g.x,
|
||
active: false,
|
||
loop: null,
|
||
edge,
|
||
};
|
||
} else if (area === 'ruler') {
|
||
this.gesture = {
|
||
kind: 'cursor',
|
||
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
|
||
};
|
||
} else if (area === 'lanes') this.pressLanes(asset, e, g);
|
||
this.invalidate();
|
||
}
|
||
|
||
private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void {
|
||
const row = trackAtY(this.view, asset.tracks.length, y);
|
||
if (row === null) return;
|
||
const track = asset.tracks[row]!;
|
||
if (e.button === 2) {
|
||
this.openTrackMenu(asset, row, e.global);
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
||
if (part === 'fx') this.openTrackFxMenu(asset, track, e.global);
|
||
else if (part === 'mute' || part === 'solo') {
|
||
const label =
|
||
part === 'mute'
|
||
? track.mute
|
||
? 'Unmute track'
|
||
: 'Mute track'
|
||
: track.solo
|
||
? 'Unsolo track'
|
||
: 'Solo track';
|
||
this.commit(
|
||
asset,
|
||
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: !t[part] } : t)) },
|
||
label,
|
||
);
|
||
} else if (
|
||
(part === 'volume' || part === 'pan') &&
|
||
pressCount(e, `song-slider:${track.id}:${part}`) === 2
|
||
) {
|
||
// A double-click sets it back to the middle: 0 dB, or straight ahead.
|
||
this.commit(
|
||
asset,
|
||
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: 0 } : t)) },
|
||
part === 'volume' ? 'Reset track volume' : 'Reset track pan',
|
||
);
|
||
this.onTracksPreview(this.asset()?.tracks ?? asset.tracks);
|
||
} else if (part === 'volume' || part === 'pan') {
|
||
const value = this.sliderAt(part, x);
|
||
this.gesture = { kind: 'slider', track: track.id, part, value };
|
||
this.onTracksPreview(this.shownTracks(asset));
|
||
}
|
||
if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id);
|
||
this.invalidate();
|
||
}
|
||
|
||
private sliderAt(part: 'volume' | 'pan', x: number): number {
|
||
return part === 'volume'
|
||
? sliderValue(x, HEAD_BOXES.volume, TRACK_MIN_DB, TRACK_MAX_DB, 0.5)
|
||
: sliderValue(x, HEAD_BOXES.pan, -1, 1, 0.05);
|
||
}
|
||
|
||
private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void {
|
||
const hit = itemAt(this.view, asset.items, asset.tracks, g);
|
||
const anchor = hit ? asset.items.find((i) => i.id === hit.id) : undefined;
|
||
if (!hit || !anchor) {
|
||
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey };
|
||
return;
|
||
}
|
||
if (pressCount(e, `song-item:${anchor.id}`) === 2) {
|
||
this.onOpenItem(anchor, xToTick(this.view, g.x));
|
||
return;
|
||
}
|
||
const picked = this.pickedIds;
|
||
let shift: ShiftPress = null;
|
||
if (e.shiftKey) {
|
||
shift = picked.includes(anchor.id) ? 'unpick' : 'added';
|
||
if (shift === 'added') this.pick([...picked, anchor.id]);
|
||
} else if (!picked.includes(anchor.id)) this.pick([anchor.id]);
|
||
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0, shift };
|
||
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0, shift };
|
||
else {
|
||
const grab = xToTick(this.view, g.x) - anchor.tick;
|
||
this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g, shift };
|
||
}
|
||
}
|
||
|
||
/** A click with Shift that changed nothing takes a picked item out of the picked ones. */
|
||
private unpick(anchor: SongItem, shift: ShiftPress): void {
|
||
if (shift === 'unpick') this.pick(this.pickedIds.filter((id) => id !== anchor.id));
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const g = this.inLanes({ x: local.x, y: local.y });
|
||
const asset = this.asset();
|
||
const dragged = activeDrag();
|
||
if (dragged?.kind === 'asset' && asset) {
|
||
this.trackDrop(asset, dragged.id, local, g);
|
||
return;
|
||
}
|
||
const gesture = this.gesture;
|
||
if (!asset) return;
|
||
if (!gesture) {
|
||
if (!this.take) this.followHover(asset, local, g);
|
||
return;
|
||
}
|
||
const tick = xToTick(this.view, g.x);
|
||
const grid = songGrid(asset.props);
|
||
const free = e.altKey || e.shiftKey;
|
||
const picked = new Set(this.pickedIds);
|
||
switch (gesture.kind) {
|
||
case 'pan':
|
||
this.view = clampTracks(
|
||
clampTimePan(
|
||
{
|
||
...this.view,
|
||
panX: gesture.panX + (local.x - gesture.pointer.x),
|
||
panY: gesture.panY + (local.y - gesture.pointer.y),
|
||
},
|
||
this.lanesWidth,
|
||
),
|
||
asset.tracks.length,
|
||
this.lanesHeight,
|
||
);
|
||
break;
|
||
case 'loop': {
|
||
if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return;
|
||
gesture.active = true;
|
||
const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid));
|
||
gesture.loop = draggedLoop(songLoop(asset), gesture.edge, snap(gesture.from), snap(tick));
|
||
break;
|
||
}
|
||
case 'cursor':
|
||
gesture.tick = this.cursorTick(tick, grid, free);
|
||
break;
|
||
case 'empty': {
|
||
gesture.current = g;
|
||
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
||
if (!gesture.active && span < DRAG_THRESHOLD) return;
|
||
gesture.active = true;
|
||
break;
|
||
}
|
||
case 'move': {
|
||
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
||
return;
|
||
gesture.moved = true;
|
||
const row = nearestTrack(this.view, asset.tracks.length, g.y);
|
||
const by = moveDelta(
|
||
asset.items,
|
||
picked,
|
||
indexOf(asset.tracks),
|
||
asset.tracks.length,
|
||
gesture.anchor,
|
||
tick - gesture.grab,
|
||
row,
|
||
grid,
|
||
free,
|
||
);
|
||
gesture.ticks = by.ticks;
|
||
gesture.tracks = by.tracks;
|
||
break;
|
||
}
|
||
case 'resize': {
|
||
const tempo = songTempo(asset.props);
|
||
const rounds = roundsOf(gesture.anchor, this.periodOf(gesture.anchor, tempo), tempo);
|
||
gesture.by = draggedLength(gesture.anchor, tick, grid, free, rounds) - gesture.anchor.length;
|
||
break;
|
||
}
|
||
case 'trim': {
|
||
const tempo = songTempo(asset.props);
|
||
const period = (item: SongItem) => this.periodOf(item, tempo);
|
||
gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, tempo, period);
|
||
break;
|
||
}
|
||
case 'slider':
|
||
gesture.value = this.sliderAt(gesture.part, local.x);
|
||
this.onTracksPreview(this.shownTracks(asset));
|
||
break;
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
private onPointerUp(): void {
|
||
const drop = this.drop;
|
||
const asset = this.asset();
|
||
if (drop && asset) {
|
||
this.drop = null;
|
||
endDrag();
|
||
const item = newItem(drop);
|
||
this.commit(asset, { items: [...asset.items, item] }, 'Add item');
|
||
this.pick([item.id]);
|
||
return;
|
||
}
|
||
const gesture = this.gesture;
|
||
this.gesture = null;
|
||
if (!gesture || !asset) return;
|
||
const picked = new Set(this.pickedIds);
|
||
switch (gesture.kind) {
|
||
case 'loop': {
|
||
const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset));
|
||
if (gesture.active && gesture.loop && !same)
|
||
this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop));
|
||
break;
|
||
}
|
||
case 'cursor':
|
||
this.setStart(gesture.tick);
|
||
break;
|
||
case 'empty': {
|
||
const caught = gesture.active
|
||
? itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current)
|
||
: [];
|
||
const kept = gesture.add ? this.pickedIds : [];
|
||
if (caught.length > 0 || kept.length > 0) this.pick([...new Set([...kept, ...caught])]);
|
||
else this.state.select({ kind: 'asset', assetId: asset.id });
|
||
break;
|
||
}
|
||
case 'move':
|
||
if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0))
|
||
this.commit(
|
||
asset,
|
||
{
|
||
items: moved(
|
||
asset.items,
|
||
picked,
|
||
asset.tracks.map((t) => t.id),
|
||
gesture.ticks,
|
||
gesture.tracks,
|
||
),
|
||
},
|
||
picked.size === 1 ? 'Move item' : 'Move items',
|
||
);
|
||
else if (!gesture.moved) {
|
||
if (gesture.shift === null) this.pick([gesture.anchor.id]);
|
||
else this.unpick(gesture.anchor, gesture.shift);
|
||
}
|
||
break;
|
||
case 'resize':
|
||
if (gesture.by !== 0)
|
||
this.commit(
|
||
asset,
|
||
{ items: resized(asset.items, picked, gesture.by) },
|
||
picked.size === 1 ? 'Resize item' : 'Resize items',
|
||
);
|
||
else this.unpick(gesture.anchor, gesture.shift);
|
||
break;
|
||
case 'trim':
|
||
if (gesture.by !== 0)
|
||
this.commit(
|
||
asset,
|
||
{ items: this.trimmedItems(asset, picked, gesture.by) },
|
||
picked.size === 1 ? 'Trim item' : 'Trim items',
|
||
);
|
||
else this.unpick(gesture.anchor, gesture.shift);
|
||
break;
|
||
case 'slider': {
|
||
const tracks = this.shownTracksWith(asset, gesture);
|
||
this.commit(asset, { tracks }, gesture.part === 'volume' ? 'Set track volume' : 'Set track pan');
|
||
this.onTracksPreview(this.asset()?.tracks ?? tracks);
|
||
break;
|
||
}
|
||
}
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */
|
||
private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
|
||
const dragged = findAsset(this.state.project, assetId);
|
||
// Left of tick 0 is there to look at: nothing lands there.
|
||
const inside =
|
||
local.x >= HEAD_WIDTH &&
|
||
local.x <= this.w &&
|
||
local.y >= RULER_HEIGHT &&
|
||
local.y <= this.h &&
|
||
xToTick(this.view, g.x) >= 0;
|
||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
|
||
if (this.drop) {
|
||
this.drop = null;
|
||
this.invalidate();
|
||
}
|
||
return;
|
||
}
|
||
const grid = songGrid(asset.props);
|
||
const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid));
|
||
const bar = ticksPerBar(songMeter(asset.props));
|
||
let length: number;
|
||
if (dragged.type === 'pattern')
|
||
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
|
||
else {
|
||
const played = playedSoundOf(this.state.project, dragged);
|
||
this.ensureLoaded(played);
|
||
length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar);
|
||
}
|
||
this.drop = { kind: dragged.type, asset: dragged.id, track: asset.tracks[row]!.id, tick, length };
|
||
this.invalidate();
|
||
}
|
||
|
||
/** The part of an item under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
|
||
private followHover(asset: SongAsset, local: Vec2, g: Vec2): void {
|
||
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
|
||
const hit = inside ? itemAt(this.view, asset.items, asset.tracks, g) : null;
|
||
const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default';
|
||
if (this.cursor !== cursor) this.cursor = cursor;
|
||
if (JSON.stringify(hit) === JSON.stringify(this.hover)) return;
|
||
this.hover = hit;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Where a paste goes with the pointer over the lanes: the grid line before it and its track; else null. */
|
||
private pasteSpot(asset: SongAsset): { tick: number; row: number } | null {
|
||
const p = this.pointer;
|
||
if (!p || p.x > this.w || p.y > this.h || this.areaAt(p) !== 'lanes') return null;
|
||
const g = this.inLanes(p);
|
||
const tick = xToTick(this.view, g.x);
|
||
// Left of tick 0 nothing lands, as nothing is dropped there.
|
||
if (tick < 0) return null;
|
||
return {
|
||
tick: snapDown(tick, songGrid(asset.props)),
|
||
row: nearestTrack(this.view, asset.tracks.length, g.y),
|
||
};
|
||
}
|
||
|
||
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt or Shift on the tick. */
|
||
private cursorTick(tick: number, grid: number, free: boolean): number {
|
||
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
|
||
}
|
||
|
||
private setStart(tick: number): void {
|
||
this.startTick = Math.max(0, tick);
|
||
this.onStart(this.startTick);
|
||
this.invalidate();
|
||
}
|
||
|
||
private commit(asset: SongAsset, parts: SongParts, label: string): void {
|
||
if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label));
|
||
}
|
||
|
||
private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void {
|
||
const bar = ticksPerBar(songMeter(asset.props));
|
||
const first = snapDown(tick, bar);
|
||
new Menu([
|
||
{
|
||
label: `Loop bar ${first / bar + 1}`,
|
||
onSelect: () =>
|
||
this.state.execute(new SetSongLoopCommand(asset.id, { start: first, end: first + bar })),
|
||
},
|
||
{
|
||
label: 'Loop the whole song',
|
||
disabled: !asset.loop,
|
||
onSelect: () => this.state.execute(new SetSongLoopCommand(asset.id, null)),
|
||
},
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
/** The menu of the item under a right-click, picked first unless it is already; off one, of the spot. */
|
||
private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||
const at = itemAt(this.view, asset.items, asset.tracks, g);
|
||
const hit = at && asset.items.find((i) => i.id === at.id);
|
||
if (!hit) return this.openSpotMenu(asset, g, global);
|
||
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
|
||
const played = findAsset(this.state.project, hit.asset);
|
||
const embedded = played !== undefined && isEmbedded(played);
|
||
new Menu([
|
||
{ label: 'Open', disabled: !played, onSelect: () => this.onOpenItem(hit, hit.tick) },
|
||
embedded
|
||
? {
|
||
label: 'Make project asset',
|
||
onSelect: () => this.state.execute(new MakeProjectAssetCommand(hit.asset)),
|
||
}
|
||
: { label: 'Embed a copy', disabled: !played, onSelect: () => this.embedCopy(asset, hit) },
|
||
{ separator: true },
|
||
{ label: 'Delete', onSelect: () => this.deleteSelection() },
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
/** The menu of an empty spot on a track. */
|
||
private openSpotMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||
const tick = xToTick(this.view, g.x);
|
||
if (row === null || tick < 0) return;
|
||
new Menu([{ label: 'Create pattern', onSelect: () => this.createPattern(asset, row, tick) }]).open(
|
||
global.x,
|
||
global.y,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A pattern the song keeps to itself (#355), a bar long on the song's clock, on an item from the
|
||
* grid line before `tick` on; opened right away. It plays the instrument of the track's pattern
|
||
* nearest before it.
|
||
*/
|
||
createPattern(asset: SongAsset, row: number, tick: number): boolean {
|
||
const track = asset.tracks[row];
|
||
if (!track || tick < 0) return false;
|
||
const project = this.state.project;
|
||
const start = snapDown(tick, songGrid(asset.props));
|
||
const create = new CreateAssetCommand(
|
||
'pattern',
|
||
embeddedPatternName(project, asset.id),
|
||
{
|
||
...songClockProps(asset.props),
|
||
[PATTERN_BARS_KEY]: 1,
|
||
[PATTERN_INSTRUMENT_KEY]: instrumentNear(project, asset.items, track.id, start),
|
||
},
|
||
asset.id,
|
||
);
|
||
const length = ticksPerBar(songMeter(asset.props));
|
||
const item = newItem({ track: track.id, kind: 'pattern', asset: create.asset.id, tick: start, length });
|
||
const placed = new SetSongCommand(asset.id, { items: [...asset.items, item] }, 'Create pattern');
|
||
if (!this.state.execute(new CompoundCommand('Create pattern', [create, placed]))) return false;
|
||
this.pick([item.id]);
|
||
this.onOpenItem(item, item.tick);
|
||
return true;
|
||
}
|
||
|
||
/** Points the item at a copy of its asset that only the song has (#355), to bend without touching the rest. */
|
||
embedCopy(asset: SongAsset, item: SongItem): boolean {
|
||
const played = findAsset(this.state.project, item.asset);
|
||
if (!played || isEmbedded(played)) return false;
|
||
const copy = embeddedCopy(played, asset);
|
||
this.commit(
|
||
asset,
|
||
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] },
|
||
'Embed a copy',
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
|
||
private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void {
|
||
const set = (fx: string) =>
|
||
this.commit(
|
||
asset,
|
||
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
|
||
'Set track sound FX',
|
||
);
|
||
const effects = listedAssets(this.state.project)
|
||
.filter((a) => a.type === 'soundfx')
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
new Menu([
|
||
{ label: 'No sound FX', current: track.fx === '', onSelect: () => set('') },
|
||
...(effects.length > 0 ? [{ separator: true as const }] : []),
|
||
...effects.map((fx) => ({ label: fx.name, current: fx.id === track.fx, onSelect: () => set(fx.id) })),
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
|
||
const track = asset.tracks[row]!;
|
||
const swap = (other: number) => {
|
||
const tracks = [...asset.tracks];
|
||
[tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!];
|
||
this.commit(asset, { tracks }, 'Move track');
|
||
};
|
||
new Menu([
|
||
{ label: 'Add track above', onSelect: () => this.addTrack(row) },
|
||
{ label: 'Add track below', onSelect: () => this.addTrack(row + 1) },
|
||
{ label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) },
|
||
{ label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) },
|
||
{
|
||
label: 'Delete track',
|
||
disabled: asset.tracks.length <= 1,
|
||
onSelect: () => {
|
||
this.pick([], track.id);
|
||
this.state.execute(
|
||
new SetSongCommand(
|
||
asset.id,
|
||
{
|
||
tracks: asset.tracks.filter((t) => t.id !== track.id),
|
||
items: asset.items.filter((i) => i.track !== track.id),
|
||
},
|
||
'Delete track',
|
||
),
|
||
);
|
||
},
|
||
},
|
||
]).open(global.x, global.y);
|
||
}
|
||
|
||
private onWheel(e: FederatedWheelEvent): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const local = this.toLocal(e.global);
|
||
const area = this.areaAt({ x: local.x, y: local.y });
|
||
const g = this.inLanes({ x: local.x, y: local.y });
|
||
const factor = zoomFactor(e.deltaY);
|
||
let view: SongView;
|
||
if (area === 'heads') view = zoomTracks(this.view, factor, g.y);
|
||
else if (area === 'ruler' || area === 'lanes')
|
||
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
|
||
else return;
|
||
this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
e.preventDefault();
|
||
}
|
||
|
||
// --- drawing ---------------------------------------------------------------------------------
|
||
|
||
/** The items as the running gesture would leave them. */
|
||
private shownItems(asset: SongAsset): readonly SongItem[] {
|
||
const g = this.gesture;
|
||
const picked = new Set(this.pickedIds);
|
||
if (g?.kind === 'move' && g.moved)
|
||
return moved(
|
||
asset.items,
|
||
picked,
|
||
asset.tracks.map((t) => t.id),
|
||
g.ticks,
|
||
g.tracks,
|
||
);
|
||
if (g?.kind === 'resize') return resized(asset.items, picked, g.by);
|
||
if (g?.kind === 'trim') return this.trimmedItems(asset, picked, g.by);
|
||
return asset.items;
|
||
}
|
||
|
||
private trimmedItems(asset: SongAsset, picked: ReadonlySet<string>, by: number): SongItem[] {
|
||
const tempo = songTempo(asset.props);
|
||
return trimmed(asset.items, picked, by, tempo, (item) => this.periodOf(item, tempo));
|
||
}
|
||
|
||
/** How long one round of what an item plays is, in ticks: its pattern, or its sound once loaded. */
|
||
private periodOf(item: SongItem, tempo: number): number | null {
|
||
if (item.kind === 'pattern') {
|
||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||
return pattern ? patternLength(pattern) : null;
|
||
}
|
||
const played = playedSoundById(this.state.project, item.asset);
|
||
const seconds = played ? soundLibrary.duration(played) : null;
|
||
return seconds ? ticksOfSeconds(seconds, tempo) : null;
|
||
}
|
||
|
||
private shownTracks(asset: SongAsset): SongTrack[] {
|
||
const g = this.gesture;
|
||
const tracks = g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks;
|
||
if (!this.take) return tracks;
|
||
const shown = [...tracks];
|
||
shown.splice(Math.min(this.take.index, shown.length), 0, {
|
||
id: TAKE_TRACK,
|
||
name: 'Recording',
|
||
volume: 0,
|
||
pan: 0,
|
||
mute: false,
|
||
solo: false,
|
||
fx: '',
|
||
});
|
||
return shown;
|
||
}
|
||
|
||
private shownTracksWith(asset: SongAsset, g: Extract<Gesture, { kind: 'slider' }>): SongTrack[] {
|
||
return asset.tracks.map((t) => (t.id === g.track ? { ...t, [g.part]: g.value } : t));
|
||
}
|
||
|
||
private shownLoop(): LoopRange | null {
|
||
const asset = this.asset();
|
||
if (!asset) return null;
|
||
const g = this.gesture;
|
||
if (g?.kind === 'loop' && g.active) return g.loop;
|
||
return songLoop(asset);
|
||
}
|
||
|
||
/** Where playing starts, as a drag on the ruler would leave it. */
|
||
private shownStart(): number {
|
||
return this.gesture?.kind === 'cursor' ? this.gesture.tick : this.startTick;
|
||
}
|
||
|
||
/** Where what an item plays starts over inside it: each round of its pattern, or of its sound once loaded. */
|
||
private seamsOf(item: SongItem): number[] {
|
||
const asset = this.asset();
|
||
if (!asset) return [];
|
||
const tempo = songTempo(asset.props);
|
||
const rounds = roundsOf(item, this.periodOf(item, tempo), tempo);
|
||
return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : [];
|
||
}
|
||
|
||
/** Its asset's name, with a mark in front for one the song keeps to itself (#355). */
|
||
private labelOf(item: SongItem): string {
|
||
const asset = findAsset(this.state.project, item.asset);
|
||
if (!asset) return 'missing';
|
||
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
|
||
}
|
||
|
||
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
|
||
if (item.kind === 'pattern') {
|
||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||
return pattern && pattern.notes.length > 0 ? 'notes' : 'none';
|
||
}
|
||
const played = playedSoundById(this.state.project, item.asset);
|
||
return played && soundLibrary.peaks(played) ? 'wave' : 'none';
|
||
}
|
||
|
||
private ensureLoaded(file: string): void {
|
||
if (!file || this.loading.has(file) || soundLibrary.duration(file) !== null) return;
|
||
this.loading.add(file);
|
||
void soundLibrary.load(file).then(() => this.invalidate());
|
||
}
|
||
|
||
override layout(): void {
|
||
if (this.needsFit) this.fit();
|
||
}
|
||
|
||
override redraw(): void {
|
||
if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) {
|
||
this.fit();
|
||
return;
|
||
}
|
||
const c = Theme.colors;
|
||
const w = this.w;
|
||
const h = this.h;
|
||
const lw = this.lanesWidth;
|
||
const lh = this.lanesHeight;
|
||
this.bg.clear().rect(0, 0, w, h).fill(c.viewport);
|
||
this.bg.rect(0, 0, HEAD_WIDTH, h).fill(c.panel);
|
||
this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader);
|
||
this.bg.rect(HEAD_WIDTH - 1, 0, 1, h).fill(c.border);
|
||
this.clips.lanes.clear().rect(HEAD_WIDTH, RULER_HEIGHT, lw, lh).fill(0xffffff);
|
||
this.clips.heads
|
||
.clear()
|
||
.rect(0, RULER_HEIGHT, HEAD_WIDTH - 1, lh)
|
||
.fill(0xffffff);
|
||
this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff);
|
||
this.lanesLayer.position.set(HEAD_WIDTH, RULER_HEIGHT);
|
||
this.headsLayer.position.set(0, RULER_HEIGHT);
|
||
this.ruler.position.set(HEAD_WIDTH, 0);
|
||
|
||
const asset = this.asset();
|
||
this.lanesG.clear();
|
||
this.itemsG.clear();
|
||
this.overlay.clear();
|
||
this.headsG.clear();
|
||
if (!asset) {
|
||
this.syncTexts(this.itemTexts, this.itemLabels, []);
|
||
this.syncTexts(this.headTexts, this.headLabels, []);
|
||
this.ruler.visible = false;
|
||
this.hint.text = 'No song selected – pick one in the Assets panel or create one.';
|
||
this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||
this.hint.visible = true;
|
||
return;
|
||
}
|
||
this.ruler.visible = true;
|
||
const length = songLength(asset);
|
||
const tracks = this.shownTracks(asset);
|
||
this.drawLanes(asset, tracks, lw, lh, length);
|
||
this.drawItems(asset, lw, lh);
|
||
this.drawHeads(tracks);
|
||
this.ruler.draw(this.view, songMeter(asset.props), lw, {
|
||
length,
|
||
loop: this.shownLoop(),
|
||
start: this.shownStart(),
|
||
playhead: this.playhead,
|
||
});
|
||
const g = this.gesture;
|
||
if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current);
|
||
if (this.drop) {
|
||
const row = indexOf(asset.tracks).get(this.drop.track);
|
||
if (row !== undefined) {
|
||
const box = itemBox(this.view, row, this.drop);
|
||
this.overlay
|
||
.rect(box.x, box.y, box.w, box.h)
|
||
.fill({ color: c.accent, alpha: 0.25 })
|
||
.stroke({ width: 1, color: c.accent });
|
||
}
|
||
}
|
||
const start = tickToX(this.view, this.shownStart());
|
||
if (start >= 0 && start <= lw) this.overlay.rect(start, 0, 1, lh).fill({ color: c.text, alpha: 0.3 });
|
||
if (this.playhead !== null) {
|
||
const x = tickToX(this.view, this.playhead);
|
||
if (x >= 0 && x <= lw) this.overlay.rect(x - 0.5, 0, 1, lh).fill(c.hot);
|
||
}
|
||
this.hint.text =
|
||
asset.items.length === 0
|
||
? 'Drag a pattern or a sound from the Assets panel onto a track. Space plays.'
|
||
: '';
|
||
this.hint.visible = this.hint.text !== '';
|
||
this.hint.position.set(
|
||
HEAD_WIDTH + Theme.padding * 2,
|
||
RULER_HEIGHT + trackTop(this.view, tracks.length) + Theme.padding * 2,
|
||
);
|
||
}
|
||
|
||
/** A band per track, the picked one lit, and the bar, beat and grid lines across them. */
|
||
private drawLanes(
|
||
asset: SongAsset,
|
||
tracks: readonly SongTrack[],
|
||
lw: number,
|
||
lh: number,
|
||
length: number,
|
||
): void {
|
||
const c = Theme.colors;
|
||
tracks.forEach((track, i) => {
|
||
const y = trackTop(this.view, i);
|
||
const th = this.view.trackHeight;
|
||
if (y > lh || y + th < 0) return;
|
||
const lit = track.id === this.pickedTrack && this.pickedIds.length === 0;
|
||
this.lanesG
|
||
.rect(0, y, lw, th)
|
||
.fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 });
|
||
if (i === 0) this.lanesG.rect(0, y, lw, 1).fill({ color: c.border, alpha: 0.8 });
|
||
this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
|
||
});
|
||
// Above the first track and below the last, panned to, is empty.
|
||
const top = Math.max(0, trackTop(this.view, 0));
|
||
const bottom = Math.min(lh, trackTop(this.view, tracks.length));
|
||
if (bottom <= top) return;
|
||
const alpha = [0.9, 0.45, 0.2];
|
||
for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw))
|
||
this.lanesG.rect(line.x, top, 1, bottom - top).fill({ color: c.border, alpha: alpha[line.level] });
|
||
const end = tickToX(this.view, length);
|
||
if (end < lw)
|
||
this.lanesG
|
||
.rect(Math.max(0, end), top, lw - Math.max(0, end), bottom - top)
|
||
.fill({ color: 0x000000, alpha: 0.2 });
|
||
const zero = Math.min(lw, tickToX(this.view, 0));
|
||
if (zero > 0) this.lanesG.rect(0, top, zero, bottom - top).fill({ color: 0x000000, alpha: 0.4 });
|
||
}
|
||
|
||
private drawItems(asset: SongAsset, lw: number, lh: number): void {
|
||
const c = Theme.colors;
|
||
const rows = indexOf(this.shownTracks(asset));
|
||
const picked = new Set(this.pickedIds);
|
||
const labels: Placed[] = [];
|
||
for (const item of this.shownItems(asset)) {
|
||
const row = rows.get(item.track);
|
||
if (row === undefined) continue;
|
||
const box = itemBox(this.view, row, item);
|
||
if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue;
|
||
const missing = !findAsset(this.state.project, item.asset);
|
||
const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR;
|
||
const lit = picked.has(item.id);
|
||
this.itemsG
|
||
.roundRect(box.x, box.y, box.w, box.h, 3)
|
||
.fill({ color, alpha: lit ? 1 : 0.85 })
|
||
.stroke({
|
||
width: lit ? 2 : 1,
|
||
color: lit ? c.accentText : 0x000000,
|
||
alpha: lit ? 1 : 0.45,
|
||
alignment: 1,
|
||
});
|
||
if (item.kind === 'pattern') this.drawNotes(item, box, lw);
|
||
else this.drawWave(item, box, lw);
|
||
this.drawSeams(item, box, lw);
|
||
if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part);
|
||
if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 });
|
||
}
|
||
const take = this.take && this.drawTake(this.take, lw);
|
||
if (take) labels.push(take);
|
||
this.syncTexts(this.itemTexts, this.itemLabels, labels);
|
||
}
|
||
|
||
/** The take growing on its track as it is recorded, red, with what came in so far. */
|
||
private drawTake(take: TakeView, lw: number): Placed | null {
|
||
const box = itemBox(this.view, take.index, { tick: take.tick, length: Math.max(1, take.length) });
|
||
if (box.x > lw || box.x + box.w < 0) return null;
|
||
this.itemsG
|
||
.roundRect(box.x, box.y, box.w, box.h, 3)
|
||
.fill({ color: TAKE_COLOR, alpha: take.uploading ? 0.55 : 0.9 })
|
||
.stroke({ width: 1, color: 0x000000, alpha: 0.45, alignment: 1 });
|
||
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
|
||
const half = (box.h - LABEL_STRIP - 4) / 2;
|
||
const perPx = 1 / this.view.pxPerTick / take.ticksPerPeak;
|
||
for (let x = Math.max(0, Math.ceil(box.x)); x < Math.min(lw, box.x + box.w); x += 2) {
|
||
const from = Math.floor((x - box.x) * perPx);
|
||
const to = Math.max(from + 1, Math.floor((x + 2 - box.x) * perPx));
|
||
let peak = 0;
|
||
for (let i = from; i < to && i < take.peaks.length; i++) peak = Math.max(peak, take.peaks[i]!);
|
||
if (from >= take.peaks.length) break;
|
||
const reach = Math.max(0.5, peak * half);
|
||
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
|
||
}
|
||
if (box.w <= 18) return null;
|
||
const text = take.uploading ? 'Uploading…' : 'Recording…';
|
||
return { text, x: box.x + 4, y: box.y + 1, w: box.w - 8 };
|
||
}
|
||
|
||
/** A line where what the item plays starts over, the label strip left clear. */
|
||
private drawSeams(item: SongItem, box: Box, lw: number): void {
|
||
for (const tick of this.seamsOf(item)) {
|
||
const x = Math.round(tickToX(this.view, tick));
|
||
if (x <= box.x + 1 || x >= box.x + box.w - 1 || x < 0 || x > lw) continue;
|
||
this.itemsG
|
||
.rect(x - 1, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP)
|
||
.fill({ color: 0x000000, alpha: 0.5 });
|
||
this.itemsG.rect(x, box.y + LABEL_STRIP, 1, box.h - LABEL_STRIP).fill({ color: 0xffffff, alpha: 0.55 });
|
||
this.itemsG
|
||
.moveTo(x - 3, box.y + LABEL_STRIP)
|
||
.lineTo(x + 4, box.y + LABEL_STRIP)
|
||
.lineTo(x + 0.5, box.y + LABEL_STRIP + 4)
|
||
.closePath()
|
||
.fill({ color: 0xffffff, alpha: 0.8 });
|
||
}
|
||
}
|
||
|
||
/** The end of an item the pointer would drag, lit. */
|
||
private drawGrip(box: Box, part: 'start' | 'end'): void {
|
||
const reach = Math.min(EDGE_REACH, box.w / 3);
|
||
const x = part === 'end' ? box.x + box.w - reach : box.x;
|
||
this.itemsG.rect(x, box.y, reach, box.h).fill({ color: 0xffffff, alpha: 0.28 });
|
||
this.itemsG
|
||
.rect(part === 'end' ? box.x + box.w - 2 : box.x, box.y, 2, box.h)
|
||
.fill(Theme.colors.accentText);
|
||
}
|
||
|
||
/** The notes of a pattern item, small, across every round it plays. */
|
||
private drawNotes(item: SongItem, box: Box, lw: number): void {
|
||
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
|
||
if (!pattern || pattern.notes.length === 0) return;
|
||
const span = patternLength(pattern);
|
||
const notes = pattern.notes.filter((n) => n.tick < span);
|
||
if (notes.length === 0) return;
|
||
const low = Math.min(...notes.map((n) => n.note));
|
||
const high = Math.max(...notes.map((n) => n.note));
|
||
const top = box.y + LABEL_STRIP;
|
||
const room = Math.max(4, box.h - LABEL_STRIP - 3);
|
||
const step = room / Math.max(1, high - low + 1);
|
||
const into = ((item.offset % span) + span) % span;
|
||
const end = itemEnd(item);
|
||
for (let round = item.tick - into; round < end; round += span)
|
||
for (const n of notes) {
|
||
const from = Math.max(item.tick, round + n.tick);
|
||
const to = Math.min(end, round + Math.min(n.tick + n.length, span));
|
||
if (to <= from) continue;
|
||
const x0 = tickToX(this.view, from);
|
||
const x1 = tickToX(this.view, to);
|
||
if (x1 < 0 || x0 > lw) continue;
|
||
const y = top + (high - n.note) * step;
|
||
this.itemsG
|
||
.rect(x0, y, Math.max(1, x1 - x0 - 0.5), Math.max(1, Math.min(3, step - 0.5)))
|
||
.fill({ color: 0xffffff, alpha: 0.8 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The waveform of a sound item, round and round as it plays, from where it starts in it. Each
|
||
* round is drawn from its own start in the same columns, so every repeat looks alike.
|
||
*/
|
||
private drawWave(item: SongItem, box: Box, lw: number): void {
|
||
const played = playedSoundById(this.state.project, item.asset);
|
||
if (!played) return;
|
||
const peaks = soundLibrary.peaks(played);
|
||
const seconds = soundLibrary.duration(played);
|
||
if (!peaks || !seconds) {
|
||
this.ensureLoaded(played);
|
||
return;
|
||
}
|
||
const asset = this.asset()!;
|
||
const tempo = songTempo(asset.props);
|
||
const period = ticksOfSeconds(seconds, tempo);
|
||
const width = tickToX(this.view, period) - tickToX(this.view, 0);
|
||
if (!(width > 0)) return;
|
||
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
|
||
const half = (box.h - LABEL_STRIP - 4) / 2;
|
||
const from = Math.max(box.x, 0);
|
||
const to = Math.min(box.x + box.w, lw);
|
||
const into = ticksOfSeconds(item.offset % seconds, tempo);
|
||
const firstRound = Math.floor((xToTick(this.view, from) - item.tick + into) / period);
|
||
for (let round = firstRound; ; round++) {
|
||
const start = Math.round(tickToX(this.view, item.tick - into + round * period));
|
||
if (start >= to) break;
|
||
for (let dx = Math.max(0, Math.ceil((from - start) / 2) * 2); dx < width; dx += 2) {
|
||
const x = start + dx;
|
||
if (x >= to) break;
|
||
const reach = Math.max(0.5, peakOver(peaks, dx / width, (dx + 2) / width) * half);
|
||
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The heads: name, mute and solo, the volume and pan sliders. A track is added from a head's menu. */
|
||
private drawHeads(tracks: readonly SongTrack[]): void {
|
||
const c = Theme.colors;
|
||
const labels: Placed[] = [];
|
||
tracks.forEach((track, i) => {
|
||
const y = trackTop(this.view, i);
|
||
const lit = track.id === this.pickedTrack;
|
||
const th = this.view.trackHeight;
|
||
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
|
||
if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border);
|
||
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
|
||
const { name, fx, mute, solo, volume, pan } = HEAD_BOXES;
|
||
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
|
||
const hasFx = track.fx !== '';
|
||
this.toggle(fx, y, hasFx, FX_COLOR);
|
||
labels.push({ text: 'FX', x: fx.x, y: y + fx.y, w: fx.w, h: fx.h, dark: hasFx });
|
||
this.toggle(mute, y, track.mute, MUTE_COLOR);
|
||
this.toggle(solo, y, track.solo, SOLO_COLOR);
|
||
labels.push(
|
||
{ text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute },
|
||
{ text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo },
|
||
);
|
||
if (th < SLIDERS_MIN_HEIGHT) return;
|
||
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
|
||
this.headsG
|
||
.rect(volume.x, y + volume.y, volume.w, volume.h)
|
||
.fill(c.viewport)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
this.headsG
|
||
.rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2)
|
||
.fill({ color: c.accent, alpha: 0.8 });
|
||
const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB);
|
||
this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 });
|
||
this.headsG
|
||
.rect(pan.x, y + pan.y, pan.w, pan.h)
|
||
.fill(c.viewport)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
const centre = pan.x + pan.w / 2;
|
||
const knob = pan.x + pan.w * sliderShare(track.pan, -1, 1);
|
||
this.headsG
|
||
.rect(Math.min(centre, knob), y + pan.y + 2, Math.max(1, Math.abs(knob - centre)), pan.h - 4)
|
||
.fill({ color: c.accent, alpha: 0.8 });
|
||
this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 });
|
||
});
|
||
this.syncTexts(this.headTexts, this.headLabels, labels);
|
||
}
|
||
|
||
private toggle(box: Box, y: number, on: boolean, color: number): void {
|
||
const c = Theme.colors;
|
||
this.headsG
|
||
.roundRect(box.x, y + box.y, box.w, box.h, 3)
|
||
.fill(on ? color : c.viewport)
|
||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||
}
|
||
|
||
/** Puts texts at their places, cut short with "…" where they would run past their width. */
|
||
private syncTexts(pool: Text[], parent: Container, labels: readonly Placed[]): void {
|
||
labels.forEach((label, i) => {
|
||
let text = pool[i];
|
||
if (!text) {
|
||
text = crispText({ style: textStyle('small') });
|
||
pool.push(text);
|
||
parent.addChild(text);
|
||
}
|
||
text.style = label.dark ? DARK_LABEL : textStyle('small');
|
||
if (text.text !== label.text) text.text = label.text;
|
||
for (let cut = label.text.length; text.width > label.w && cut > 1; cut--)
|
||
text.text = `${label.text.slice(0, cut - 1)}…`;
|
||
text.visible = true;
|
||
if (label.h === undefined) text.position.set(label.x, label.y);
|
||
else
|
||
text.position.set(
|
||
Math.round(label.x + (label.w - text.width) / 2),
|
||
Math.round(label.y + (label.h - text.height) / 2),
|
||
);
|
||
});
|
||
for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false;
|
||
}
|
||
}
|