feat: auto-save and multiple tracks
|
Before Width: | Height: | Size: 352 B After Width: | Height: | Size: 352 B |
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 344 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 582 B |
118
src/Beat.ts
@@ -2,6 +2,7 @@ import Track, {TrackEvents, TrackInitOptions} from "@/Track";
|
||||
import {IPublisher, Publisher} from "@/Publisher";
|
||||
import ISubscriber from "@/Subscriber";
|
||||
import {greatestCommonDivisor, isPosInt} from "@/utils";
|
||||
import Ref from "@/Ref";
|
||||
|
||||
type BeatGroupInitOptions = {
|
||||
barCount: number;
|
||||
@@ -13,6 +14,17 @@ type BeatGroupInitOptions = {
|
||||
name?: string,
|
||||
};
|
||||
|
||||
export type BeatSerial = {
|
||||
tracks: Record<string, any>[],
|
||||
barCount: number,
|
||||
timeSigUp: number,
|
||||
globalLoopLength: number,
|
||||
globalIsLooping: boolean,
|
||||
useAutoBeatLength: boolean,
|
||||
barSettingsLocked: boolean,
|
||||
name: string,
|
||||
};
|
||||
|
||||
export const enum BeatEvents {
|
||||
TrackOrderChanged="be-0",
|
||||
TrackListChanged="be-1",
|
||||
@@ -22,14 +34,17 @@ export const enum BeatEvents {
|
||||
LockingChanged="be-5",
|
||||
GlobalLoopLengthChanged="be-5",
|
||||
GlobalDisplayTypeChanged="be-6",
|
||||
NameChanged="be-7",
|
||||
DeepChange="be-7",
|
||||
}
|
||||
|
||||
type EventTypeSubscriptions =
|
||||
| TrackEvents.LoopLengthChanged
|
||||
| TrackEvents.DisplayTypeChanged
|
||||
| TrackEvents.WantsRemoval
|
||||
| TrackEvents.Baked;
|
||||
const EventTypeSubscriptions = [
|
||||
TrackEvents.LoopLengthChanged,
|
||||
TrackEvents.DisplayTypeChanged,
|
||||
TrackEvents.WantsRemoval,
|
||||
TrackEvents.DeepChange,
|
||||
TrackEvents.Baked,
|
||||
];
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTypeSubscriptions> {
|
||||
private static globalCounter = 0;
|
||||
@@ -41,14 +56,14 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
|
||||
private globalIsLooping: boolean;
|
||||
private useAutoBeatLength: boolean;
|
||||
private barSettingsLocked = false;
|
||||
private name: string;
|
||||
private name: Ref<string>;
|
||||
|
||||
constructor(options?: BeatGroupInitOptions) {
|
||||
Beat.globalCounter++;
|
||||
if (options?.name) {
|
||||
this.name = options.name;
|
||||
this.name = Ref.new<string>(options.name);
|
||||
} else {
|
||||
this.name = `Pattern ${Beat.globalCounter}`;
|
||||
this.name = Ref.new<string>(`Pattern ${Beat.globalCounter}`);
|
||||
}
|
||||
if (options?.tracks) {
|
||||
for (const trackOptions of options.tracks) {
|
||||
@@ -62,6 +77,22 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
|
||||
this.useAutoBeatLength = options?.useAutoBeatLength ?? false;
|
||||
}
|
||||
|
||||
static deserialise(serial: any): Beat {
|
||||
if (!Beat.isBeatSerial(serial)) {
|
||||
throw new Error("Not a valid beat serial");
|
||||
}
|
||||
const newBeat = new Beat({
|
||||
loopLength: serial.globalLoopLength,
|
||||
barCount: serial.barCount,
|
||||
isLooping: serial.globalIsLooping,
|
||||
name: serial.name,
|
||||
timeSigUp: serial.timeSigUp,
|
||||
useAutoBeatLength: serial.useAutoBeatLength,
|
||||
});
|
||||
serial.tracks.forEach(trackSerial => newBeat.addTrack(Track.deserialise(trackSerial)));
|
||||
return newBeat;
|
||||
}
|
||||
|
||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
||||
switch (event) {
|
||||
case TrackEvents.LoopLengthChanged:
|
||||
@@ -75,9 +106,10 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
|
||||
this.setIsUsingAutoBeatLength(false);
|
||||
break;
|
||||
}
|
||||
this.publisher.notifySubs(BeatEvents.DeepChange);
|
||||
}
|
||||
|
||||
addSubscriber(subscriber: ISubscriber<BeatEvents>, eventType: BeatEvents | BeatEvents[]): { unbind: () => void } {
|
||||
addSubscriber(subscriber: ISubscriber<BeatEvents>, eventType: BeatEvents | Readonly<BeatEvents[]>): { unbind: () => void } {
|
||||
return this.publisher.addSubscriber(subscriber, eventType);
|
||||
}
|
||||
|
||||
@@ -220,25 +252,27 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
|
||||
return this.tracks.indexOf(this.getTrackByKey(trackKey)) < this.tracks.length - 1;
|
||||
}
|
||||
|
||||
addTrack(options?: TrackInitOptions): Track {
|
||||
options = {
|
||||
timeSig: {
|
||||
up: this.timeSigUp,
|
||||
down: 4,
|
||||
},
|
||||
bars: this.barCount,
|
||||
isLooping: this.globalIsLooping,
|
||||
loopLength: this.globalLoopLength,
|
||||
...options
|
||||
};
|
||||
const newTrack = new Track(options);
|
||||
addTrack(track: Track): void;
|
||||
addTrack(options?: TrackInitOptions): Track;
|
||||
addTrack(optionsOrTrack?: Track | TrackInitOptions): Track | void {
|
||||
let newTrack: Track;
|
||||
if (optionsOrTrack instanceof Track) {
|
||||
newTrack = optionsOrTrack;
|
||||
} else {
|
||||
optionsOrTrack = {
|
||||
timeSig: {
|
||||
up: this.timeSigUp,
|
||||
down: 4,
|
||||
},
|
||||
bars: this.barCount,
|
||||
isLooping: this.globalIsLooping,
|
||||
loopLength: this.globalLoopLength,
|
||||
...optionsOrTrack
|
||||
};
|
||||
newTrack = new Track(optionsOrTrack);
|
||||
}
|
||||
this.tracks.push(newTrack);
|
||||
newTrack.addSubscriber(this, [
|
||||
TrackEvents.LoopLengthChanged,
|
||||
TrackEvents.WantsRemoval,
|
||||
TrackEvents.DisplayTypeChanged,
|
||||
TrackEvents.Baked,
|
||||
]);
|
||||
newTrack.addSubscriber(this, EventTypeSubscriptions);
|
||||
this.publisher.notifySubs(BeatEvents.TrackListChanged);
|
||||
return newTrack;
|
||||
}
|
||||
@@ -295,11 +329,33 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
|
||||
}
|
||||
|
||||
setName(newName: string): void {
|
||||
this.name = newName;
|
||||
this.publisher.notifySubs(BeatEvents.NameChanged);
|
||||
this.name.val = newName;
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
getName(): Ref<string> {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
serialise(): Readonly<BeatSerial> {
|
||||
return {
|
||||
tracks: this.tracks.map(track => track.serialise()),
|
||||
barCount: this.barCount,
|
||||
timeSigUp: this.timeSigUp,
|
||||
globalLoopLength: this.globalLoopLength,
|
||||
globalIsLooping: this.globalIsLooping,
|
||||
useAutoBeatLength: this.useAutoBeatLength,
|
||||
barSettingsLocked: this.barSettingsLocked,
|
||||
name: this.name.val,
|
||||
} as const;
|
||||
}
|
||||
|
||||
static isBeatSerial(serial: any): serial is BeatSerial {
|
||||
return Array.isArray(serial.tracks) &&
|
||||
typeof serial.barCount === "number" &&
|
||||
typeof serial.timeSigUp === "number" &&
|
||||
typeof serial.globalLoopLength === "number" &&
|
||||
typeof serial.globalIsLooping === "boolean" &&
|
||||
typeof serial.useAutoBeatLength === "boolean" &&
|
||||
typeof serial.barSettingsLocked === "boolean";
|
||||
}
|
||||
}
|
||||
|
||||
124
src/BeatStore.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import Beat, {BeatEvents} from "@/Beat";
|
||||
import Ref from "@/Ref";
|
||||
import ISubscriber from "@/Subscriber";
|
||||
|
||||
const EventTypeSubscriptions = [
|
||||
BeatEvents.TimeSigUpChanged,
|
||||
BeatEvents.BarCountChanged,
|
||||
BeatEvents.GlobalDisplayTypeChanged,
|
||||
BeatEvents.TrackListChanged,
|
||||
BeatEvents.LockingChanged,
|
||||
BeatEvents.AutoBeatSettingsChanged,
|
||||
BeatEvents.DeepChange,
|
||||
] as const;
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
|
||||
private readonly beats: Beat[];
|
||||
private activeBeat: Ref<Beat>;
|
||||
private onBeatChangeCbs: (() => void)[] = [];
|
||||
private autoSave: boolean;
|
||||
|
||||
constructor(options: { loadFromLocalStorage: boolean, autoSave: boolean }) {
|
||||
this.autoSave = options.autoSave;
|
||||
if (options.loadFromLocalStorage) {
|
||||
const save = localStorage.getItem("drum-slayer-save");
|
||||
if (save) {
|
||||
const serial = JSON.parse(save);
|
||||
this.beats = [BeatStore.defaultMainBeatGroup()];
|
||||
this.activeBeat = Ref.new(this.beats[0]);
|
||||
this.loadFromSave(serial);
|
||||
if (this.autoSave) {
|
||||
this.activeBeat.watch(() => this.save("localStorage"), true);
|
||||
this.beats.forEach(beat => beat.addSubscriber(this, EventTypeSubscriptions));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.beats = [
|
||||
BeatStore.defaultMainBeatGroup(),
|
||||
];
|
||||
this.activeBeat = Ref.new(this.beats[0]);
|
||||
}
|
||||
|
||||
notify(publisher: unknown, event: EventTypeSubscriptions): void {
|
||||
this.save("localStorage");
|
||||
}
|
||||
|
||||
static defaultMainBeatGroup(): Beat {
|
||||
const defaultSettings = {
|
||||
barCount: 2,
|
||||
isLooping: false,
|
||||
timeSigUp: 8,
|
||||
};
|
||||
const mainBeatGroup = new Beat(defaultSettings);
|
||||
mainBeatGroup.addTrack({name: "LF"});
|
||||
mainBeatGroup.addTrack({name: "LH"});
|
||||
mainBeatGroup.addTrack({name: "RH"});
|
||||
mainBeatGroup.addTrack({name: "RF"});
|
||||
return mainBeatGroup;
|
||||
}
|
||||
|
||||
getActiveBeat(): Ref<Beat> {
|
||||
return this.activeBeat;
|
||||
}
|
||||
|
||||
resetActiveBeat(): void {
|
||||
const index = this.beats.indexOf(this.activeBeat.val);
|
||||
const reset = BeatStore.defaultMainBeatGroup();
|
||||
this.beats[index] = reset;
|
||||
this.activeBeat.val = reset;
|
||||
}
|
||||
|
||||
getBeats(): Beat[] {
|
||||
return this.beats.slice();
|
||||
}
|
||||
|
||||
setActiveBeat(beat: Beat): void {
|
||||
const index = this.beats.indexOf(beat);
|
||||
if (index !== -1) {
|
||||
this.activeBeat.val = this.beats[index];
|
||||
}
|
||||
}
|
||||
|
||||
addNewBeat(): void {
|
||||
const newBeat = BeatStore.defaultMainBeatGroup();
|
||||
this.beats.push(newBeat);
|
||||
if (this.autoSave) {
|
||||
newBeat.addSubscriber(this, EventTypeSubscriptions);
|
||||
}
|
||||
this.onBeatChangeCbs.forEach(cb => cb());
|
||||
if (this.autoSave) {
|
||||
this.save("localStorage");
|
||||
}
|
||||
}
|
||||
|
||||
onBeatChanges(callback: () => void) {
|
||||
this.onBeatChangeCbs.push(callback);
|
||||
}
|
||||
|
||||
save(destination: "localStorage"): void {
|
||||
if (destination === "localStorage") {
|
||||
const serials = this.beats.map(beat => beat.serialise());
|
||||
localStorage.setItem("drum-slayer-save", JSON.stringify({
|
||||
beats: serials,
|
||||
activeBeatIndex: this.beats.indexOf(this.activeBeat.val),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
loadFromSave(source: any): void {
|
||||
this.beats.length = 0;
|
||||
if (Array.isArray(source.beats)
|
||||
&& (typeof source.activeBeatIndex === "number" || typeof source.activeBeatIndex === "undefined")) {
|
||||
try {
|
||||
source.beats.forEach((beat: any) => this.beats.push(Beat.deserialise(beat)));
|
||||
if (typeof source.activeBeatIndex === "number") {
|
||||
this.activeBeat.val = this.beats[source.activeBeatIndex];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,12 @@ export class Publisher<EventType extends LEvent, PublisherType> implements IPubl
|
||||
this.subscribers = new Map();
|
||||
}
|
||||
|
||||
addSubscriber(subscriber: ISubscriber<EventType>, subscribeTo: EventType | EventType[]): ISubscription {
|
||||
addSubscriber(subscriber: ISubscriber<EventType>, subscribeTo: EventType | Readonly<EventType[]>): ISubscription {
|
||||
let eventTypes: EventType[] = [];
|
||||
if (!Array.isArray(subscribeTo)) {
|
||||
if (typeof subscribeTo === "string") {
|
||||
eventTypes.push(subscribeTo);
|
||||
} else {
|
||||
eventTypes = subscribeTo;
|
||||
eventTypes = subscribeTo.slice();
|
||||
}
|
||||
for (const key of eventTypes) {
|
||||
this.getSubscribers(key).push(subscriber);
|
||||
|
||||
43
src/Ref.ts
@@ -22,6 +22,7 @@ type AllowedRef = { toString(): string } | string | null;
|
||||
|
||||
export default class Ref<T extends AllowedRef = Stringable> {
|
||||
private watchers: Array<(newVal: T) => void> | null = null;
|
||||
private afterWatchers: Array<(newVal: T) => void> | null = null;
|
||||
private value: T;
|
||||
private asString?: string;
|
||||
private isString: boolean;
|
||||
@@ -39,21 +40,38 @@ export default class Ref<T extends AllowedRef = Stringable> {
|
||||
}
|
||||
}
|
||||
|
||||
watch(watcher: (newVal: T) => void): ISubscription {
|
||||
if (this.watchers === null) {
|
||||
this.watchers = [];
|
||||
watch(watcher: (newVal: T) => void, after?: boolean): ISubscription {
|
||||
if (after) {
|
||||
if (this.afterWatchers === null) {
|
||||
this.afterWatchers = [];
|
||||
}
|
||||
this.afterWatchers.push(watcher);
|
||||
} else {
|
||||
if (this.watchers === null) {
|
||||
this.watchers = [];
|
||||
}
|
||||
this.watchers.push(watcher);
|
||||
}
|
||||
this.watchers.push(watcher);
|
||||
return new RefSubscription(() => this.unbind(watcher));
|
||||
return new RefSubscription(() => this.unbind(watcher, !!after));
|
||||
}
|
||||
|
||||
private unbind(watcher: (newVal: T) => void): void {
|
||||
if (!this.watchers) {
|
||||
return;
|
||||
}
|
||||
const index = this.watchers.indexOf(watcher);
|
||||
if (index !== -1) {
|
||||
this.watchers.splice(index, 1);
|
||||
private unbind(watcher: (newVal: T) => void, after: boolean): void {
|
||||
if (after) {
|
||||
if (!this.afterWatchers) {
|
||||
return;
|
||||
}
|
||||
const index = this.afterWatchers.indexOf(watcher);
|
||||
if (index !== -1) {
|
||||
this.afterWatchers.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
if (!this.watchers) {
|
||||
return;
|
||||
}
|
||||
const index = this.watchers.indexOf(watcher);
|
||||
if (index !== -1) {
|
||||
this.watchers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +82,7 @@ export default class Ref<T extends AllowedRef = Stringable> {
|
||||
set val(val: T) {
|
||||
this.watchers?.forEach(watcher => watcher(val));
|
||||
this.value = val;
|
||||
this.afterWatchers?.forEach(watcher => watcher(val));
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
10
src/Store.ts
@@ -1,10 +0,0 @@
|
||||
import Beat from "@/Beat";
|
||||
|
||||
export default class Store {
|
||||
private beats: Beat[];
|
||||
|
||||
constructor() {
|
||||
this.beats = [];
|
||||
}
|
||||
|
||||
}
|
||||
76
src/Track.ts
@@ -1,4 +1,4 @@
|
||||
import TrackUnit from "@/TrackUnit";
|
||||
import TrackUnit, {TrackUnitType} from "@/TrackUnit";
|
||||
import {IPublisher, Publisher} from "@/Publisher";
|
||||
import ISubscriber from "@/Subscriber";
|
||||
import {isPosInt} from "@/utils";
|
||||
@@ -22,6 +22,20 @@ export const enum TrackEvents {
|
||||
LoopLengthChanged="be-4",
|
||||
WantsRemoval="be-5",
|
||||
Baked="be-6",
|
||||
DeepChange="be-7",
|
||||
}
|
||||
|
||||
export type TrackSerial = {
|
||||
name: string,
|
||||
timeSigUp: number,
|
||||
timeSigDown: number,
|
||||
units: {
|
||||
isOn: boolean[],
|
||||
type: TrackUnitType[],
|
||||
},
|
||||
barCount: number,
|
||||
loopLength: number,
|
||||
looping: boolean,
|
||||
}
|
||||
|
||||
export default class Track implements IPublisher<TrackEvents> {
|
||||
@@ -46,6 +60,30 @@ export default class Track implements IPublisher<TrackEvents> {
|
||||
this.looping = options?.isLooping ?? false;
|
||||
}
|
||||
|
||||
static deserialise(serial: any): Track {
|
||||
if (!Track.isTrackSerial(serial)) {
|
||||
throw new Error("Invalid track serial.");
|
||||
}
|
||||
const track = new Track({
|
||||
bars: serial.barCount,
|
||||
isLooping: serial.looping,
|
||||
loopLength: serial.loopLength,
|
||||
name: serial.name,
|
||||
timeSig: {
|
||||
up: serial.timeSigUp,
|
||||
down: serial.timeSigDown,
|
||||
},
|
||||
});
|
||||
const units = serial.units.isOn.map((isOn, i) => new TrackUnit({
|
||||
on: isOn,
|
||||
type: serial.units.type[i],
|
||||
parent: track,
|
||||
}));
|
||||
track.unitRecord.length = 0;
|
||||
track.unitRecord.push(...units);
|
||||
return track;
|
||||
}
|
||||
|
||||
setLoopLength(loopLength: number): void {
|
||||
if (!isPosInt(loopLength) || loopLength < 2) {
|
||||
loopLength = this.loopLength;
|
||||
@@ -105,7 +143,9 @@ export default class Track implements IPublisher<TrackEvents> {
|
||||
} else if (newBarCount > this.unitRecord.length) {
|
||||
const barsToAdd = newBarCount - this.unitRecord.length;
|
||||
for (let i = 0; i < barsToAdd; i++) {
|
||||
this.unitRecord.push(new TrackUnit());
|
||||
this.unitRecord.push(new TrackUnit({
|
||||
parent: this,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,4 +205,36 @@ export default class Track implements IPublisher<TrackEvents> {
|
||||
this.publisher.notifySubs(TrackEvents.Baked);
|
||||
}
|
||||
}
|
||||
|
||||
serialise(): Readonly<TrackSerial> {
|
||||
return {
|
||||
name: this.name,
|
||||
timeSigUp: this.timeSigUp,
|
||||
timeSigDown: this.timeSigDown,
|
||||
units: {
|
||||
isOn: this.unitRecord.map(unit => unit.isOn()),
|
||||
type: this.unitRecord.map(unit => unit.getType()),
|
||||
},
|
||||
barCount: this.barCount,
|
||||
loopLength: this.loopLength,
|
||||
looping: this.looping,
|
||||
} as const;
|
||||
}
|
||||
|
||||
static isTrackSerial(serial: any): serial is TrackSerial {
|
||||
const correctTypes = typeof serial.name === "string" &&
|
||||
typeof serial.timeSigUp === "number" &&
|
||||
typeof serial.timeSigDown === "number" &&
|
||||
typeof serial.units === "object" &&
|
||||
Array.isArray(serial.units.isOn) &&
|
||||
Array.isArray(serial.units.type) &&
|
||||
typeof serial.barCount === "number" &&
|
||||
typeof serial.loopLength === "number" &&
|
||||
typeof serial.looping === "boolean";
|
||||
return correctTypes && serial.units.isOn.length === serial.units.type.length;
|
||||
}
|
||||
|
||||
alertDeepChange(): void {
|
||||
this.publisher.notifySubs(TrackEvents.DeepChange);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {IPublisher, Publisher} from "./Publisher";
|
||||
import ISubscriber from "./Subscriber";
|
||||
import Track from "@/Track";
|
||||
|
||||
export const enum TrackUnitType {
|
||||
Normal="tut-0",
|
||||
@@ -26,10 +27,16 @@ export default class TrackUnit implements IPublisher<TrackUnitEvent> {
|
||||
private publisher: Publisher<TrackUnitEvent, TrackUnit> = new Publisher<TrackUnitEvent, TrackUnit>(this);
|
||||
private on = false;
|
||||
private typeIndex = 0;
|
||||
private parent: Track;
|
||||
|
||||
constructor(on = false, type = TrackUnitType.Normal) {
|
||||
this.on = on;
|
||||
this.setType(type);
|
||||
constructor(options: {
|
||||
on?: boolean,
|
||||
type?: TrackUnitType,
|
||||
parent: Track,
|
||||
}) {
|
||||
this.parent = options.parent;
|
||||
this.on = options.on ?? false;
|
||||
this.setType(options.type ?? TrackUnitType.Normal);
|
||||
}
|
||||
|
||||
addSubscriber(subscriber: ISubscriber<TrackUnitEvent>, eventType: TrackUnitEvent[]): { unbind: () => void } {
|
||||
@@ -44,16 +51,19 @@ export default class TrackUnit implements IPublisher<TrackUnitEvent> {
|
||||
} else {
|
||||
this.publisher.notifySubs(TrackUnitEvent.Off);
|
||||
}
|
||||
this.parent.alertDeepChange();
|
||||
}
|
||||
|
||||
setOn(on: boolean): void {
|
||||
this.on = on;
|
||||
this.publisher.notifySubs(this.on ? TrackUnitEvent.On : TrackUnitEvent.Off);
|
||||
this.parent.alertDeepChange();
|
||||
}
|
||||
|
||||
setType(type: TrackUnitType): void {
|
||||
this.typeIndex = TrackUnit.TypeRotation.indexOf(type);
|
||||
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
|
||||
this.parent.alertDeepChange();
|
||||
}
|
||||
|
||||
getType(): TrackUnitType {
|
||||
@@ -67,6 +77,7 @@ export default class TrackUnit implements IPublisher<TrackUnitEvent> {
|
||||
this.typeIndex += 1;
|
||||
}
|
||||
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
|
||||
this.parent.alertDeepChange();
|
||||
}
|
||||
|
||||
isOn(): boolean {
|
||||
|
||||
@@ -7,9 +7,28 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vertical-mode .beat {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vertical-mode .beat {
|
||||
height: inherit;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.beat-title {
|
||||
color: var(--color-title-light);
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.vertical-mode .beat-title {
|
||||
color: var(--color-title-light);
|
||||
text-align: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.beat-track-container {
|
||||
}
|
||||
@@ -4,21 +4,21 @@ import TrackView from "@/ui/Track/TrackView";
|
||||
import "./Beat.css";
|
||||
import ISubscriber from "@/Subscriber";
|
||||
import {ISubscription} from "@/Publisher";
|
||||
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
|
||||
|
||||
export type BeatUINodeOptions = UINodeOptions & {
|
||||
title: string,
|
||||
beat: Beat,
|
||||
orientation?: "horizontal" | "vertical",
|
||||
};
|
||||
|
||||
const EventTypeSubscriptions = [
|
||||
BeatEvents.TrackListChanged
|
||||
];
|
||||
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
|
||||
BeatEvents.TrackListChanged,
|
||||
] as const;
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class BeatView extends UINode implements ISubscriber<EventTypeSubscriptions> {
|
||||
private title: string;
|
||||
private beat: Beat;
|
||||
private title: EditableTextFieldView;
|
||||
private trackViews: TrackView[] = [];
|
||||
private currentOrientation: "vertical" | "horizontal";
|
||||
private subscription: ISubscription;
|
||||
@@ -26,9 +26,13 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
|
||||
constructor(options: BeatUINodeOptions) {
|
||||
super(options);
|
||||
this.beat = options.beat;
|
||||
this.title = options.title;
|
||||
this.currentOrientation = options.orientation ?? "horizontal";
|
||||
this.subscription = this.beat.addSubscriber(this, EventTypeSubscriptions);
|
||||
this.title = new EditableTextFieldView({
|
||||
setter: (text: string) => this.beat.setName(text),
|
||||
noEmpty: true,
|
||||
initialText: this.beat.getName().val,
|
||||
});
|
||||
this.setupTrackViews();
|
||||
}
|
||||
|
||||
@@ -69,19 +73,37 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
setBeat(newBeat: Beat): void {
|
||||
this.beat = newBeat;
|
||||
this.subscription.unbind();
|
||||
this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged);
|
||||
private onNewBeat(): void {
|
||||
this.beat.getName().watch((newVal) => {
|
||||
this.title.setText(newVal);
|
||||
});
|
||||
this.title.setText(this.beat.getName().val);
|
||||
EventTypeSubscriptions.forEach(event => this.notify(this, event));
|
||||
this.setupTrackViews();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
setBeat(newBeat: Beat): void {
|
||||
this.beat = newBeat;
|
||||
this.subscription.unbind();
|
||||
this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged);
|
||||
this.onNewBeat();
|
||||
}
|
||||
|
||||
build(): HTMLDivElement {
|
||||
return h("div", {
|
||||
classes: ["beat"],
|
||||
className: "beat",
|
||||
},[
|
||||
...this.trackViews
|
||||
h("h2", {
|
||||
className: "beat-title",
|
||||
}, [
|
||||
this.title,
|
||||
]),
|
||||
h("div", {
|
||||
className: "beat-track-container",
|
||||
}, [
|
||||
...this.trackViews,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const EventTypeSubscriptions = [
|
||||
BeatEvents.LockingChanged,
|
||||
BeatEvents.AutoBeatSettingsChanged,
|
||||
];
|
||||
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class BeatSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
|
||||
private beat: Beat;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
--color-ui-neutral-dark-hover: #a1a1a1;
|
||||
--color-ui-neutral-dark-active: #c1c1c1;
|
||||
--color-bg-light: #464646;
|
||||
--color-bg-medium: #323232;
|
||||
--color-bg-dark: #282828;
|
||||
--color-p-light: #fafafa;
|
||||
--color-p-light-hover: #fafafa;
|
||||
@@ -45,7 +46,6 @@
|
||||
.root-settings {
|
||||
z-index: 1;
|
||||
width: 28em;
|
||||
padding: 0 0 0 2em;
|
||||
background-color: var(--color-bg-light);
|
||||
overflow: scroll;
|
||||
display: inline-block;
|
||||
@@ -97,7 +97,7 @@
|
||||
}
|
||||
|
||||
.vertical-mode .root-beat-stage {
|
||||
margin: 5em auto auto;
|
||||
margin: auto auto;
|
||||
padding-left: 3em;
|
||||
height: 100vh;
|
||||
}
|
||||
@@ -106,6 +106,39 @@
|
||||
max-width: calc(100vw - 30em);
|
||||
}
|
||||
|
||||
.root-sidebar-left-strip {
|
||||
text-align: right;
|
||||
writing-mode: sideways-lr;
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
|
||||
.root-sidebar-left-strip > * {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.root-sidebar-left-tab {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding: 8px 3px 8px 3px;
|
||||
}
|
||||
|
||||
.root-sidebar-left-tab.active {
|
||||
background-color: var(--color-bg-medium);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.root-sidebar-add-beat {
|
||||
width: 100%;
|
||||
padding: 8px 3px 8px 3px;
|
||||
}
|
||||
|
||||
.root-sidebar-add-beat:hover,
|
||||
.root-sidebar-left-tab:hover:not(.active) {
|
||||
cursor: pointer;
|
||||
background-color: var(--color-ui-neutral-dark);
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.sidebar-visible .root-sidebar {
|
||||
left: 0;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import UINode, {h, UINodeOptions} from "@/ui/UINode";
|
||||
import UINode, {h, q, UINodeOptions} from "@/ui/UINode";
|
||||
import BeatView from "@/ui/Beat/BeatView";
|
||||
import Beat from "@/Beat";
|
||||
import "./Root.css";
|
||||
import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView";
|
||||
import IconView from "@/ui/Widgets/Icon/IconView";
|
||||
import StageTitleBarView from "@/ui/StageTitleBar/StageTitleBarView";
|
||||
import Ref from "@/Ref";
|
||||
import BeatStore from "@/BeatStore";
|
||||
|
||||
export type RootUINodeOptions = UINodeOptions & {
|
||||
title: string,
|
||||
@@ -16,24 +16,34 @@ export type RootUINodeOptions = UINodeOptions & {
|
||||
export default class RootView extends UINode {
|
||||
private title: string;
|
||||
private beatView: BeatView;
|
||||
private focusedBeat: Beat;
|
||||
private beatStore: BeatStore;
|
||||
private activeBeat: Ref<Beat>;
|
||||
private beatSettingsView: BeatSettingsView;
|
||||
private currentOrientation: "horizontal" | "vertical";
|
||||
private stageTitleBarView: StageTitleBarView;
|
||||
private showHideSidebarButton: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
|
||||
private sidebarActive = true;
|
||||
private sidebarLeftTabs: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
|
||||
|
||||
constructor(options: RootUINodeOptions) {
|
||||
super(options);
|
||||
this.beatStore = new BeatStore({
|
||||
loadFromLocalStorage: true,
|
||||
autoSave: true,
|
||||
});
|
||||
this.currentOrientation = options.orientation ?? "horizontal";
|
||||
this.focusedBeat = options.mainBeat ?? RootView.defaultMainBeatGroup();
|
||||
this.activeBeat = this.beatStore.getActiveBeat();
|
||||
this.activeBeat.watch((newVal) => {
|
||||
this.beatSettingsView.setBeat(newVal);
|
||||
this.beatView.setBeat(newVal);
|
||||
});
|
||||
this.beatView = new BeatView({
|
||||
title: options.title,
|
||||
beat: this.focusedBeat,
|
||||
beat: this.activeBeat.val,
|
||||
orientation: this.currentOrientation,
|
||||
});
|
||||
this.stageTitleBarView = new StageTitleBarView({beat: this.focusedBeat});
|
||||
this.beatSettingsView = new BeatSettingsView({beat: this.focusedBeat});
|
||||
this.beatStore.onBeatChanges(() => {
|
||||
this.sidebarLeftTabs.val?.replaceChildren(...this.buildTabs());
|
||||
});
|
||||
this.beatSettingsView = new BeatSettingsView({beat: this.activeBeat.val});
|
||||
this.title = options.title;
|
||||
this.setOrientation(this.currentOrientation);
|
||||
this.openSidebarForDesktop();
|
||||
@@ -46,27 +56,6 @@ export default class RootView extends UINode {
|
||||
}
|
||||
}
|
||||
|
||||
static defaultMainBeatGroup(): Beat {
|
||||
const defaultSettings = {
|
||||
barCount: 2,
|
||||
isLooping: false,
|
||||
timeSigUp: 8,
|
||||
};
|
||||
const mainBeatGroup = new Beat(defaultSettings);
|
||||
mainBeatGroup.addTrack({name: "LF"});
|
||||
mainBeatGroup.addTrack({name: "LH"});
|
||||
mainBeatGroup.addTrack({name: "RH"});
|
||||
mainBeatGroup.addTrack({name: "RF"});
|
||||
return mainBeatGroup;
|
||||
}
|
||||
|
||||
setMainBeatGroup(beat: Beat): void {
|
||||
this.focusedBeat = beat;
|
||||
this.beatSettingsView.setBeat(this.focusedBeat);
|
||||
this.beatView.setBeat(this.focusedBeat);
|
||||
this.stageTitleBarView.setBeat(this.focusedBeat);
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.sidebarActive = !this.sidebarActive;
|
||||
this.showHideSidebarButton.val!.title = this.sidebarText();
|
||||
@@ -95,8 +84,40 @@ export default class RootView extends UINode {
|
||||
return `${this.sidebarActive ? "Hide" : "Show"} sidebar`;
|
||||
}
|
||||
|
||||
private buildSidebarStripLeft(): HTMLElement {
|
||||
return h("div", {
|
||||
className: "root-sidebar-left-strip",
|
||||
}, [
|
||||
h("div", {
|
||||
className: "root-sidebar-add-beat",
|
||||
onclick: () => this.beatStore.addNewBeat(),
|
||||
innerText: "+",
|
||||
}),
|
||||
h("div", {
|
||||
saveTo: this.sidebarLeftTabs
|
||||
}, this.buildTabs()),
|
||||
]);
|
||||
}
|
||||
|
||||
private buildSidebarStrip(): HTMLElement {
|
||||
private buildTabs(): HTMLElement[] {
|
||||
return this.beatStore.getBeats().map((beat) => {
|
||||
const node = h("div", {
|
||||
className: "root-sidebar-left-tab" + (beat === this.activeBeat.val ? " active" : ""),
|
||||
onclick: () => this.beatStore.setActiveBeat(beat),
|
||||
innerText: beat.getName(),
|
||||
});
|
||||
this.activeBeat.watch((newVal) => {
|
||||
if (beat === newVal) {
|
||||
node.classList.add("active");
|
||||
} else {
|
||||
node.classList.remove("active");
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}).reverse();
|
||||
}
|
||||
|
||||
private buildSidebarQuickButtons(): HTMLElement {
|
||||
return h("div", {
|
||||
classes: ["root-sidebar-toggle"],
|
||||
}, [
|
||||
@@ -124,7 +145,7 @@ export default class RootView extends UINode {
|
||||
h("div", {
|
||||
classes: ["root-quick-access-button"],
|
||||
title: "Bake all tracks",
|
||||
onclick: () => this.focusedBeat.bakeLoops(),
|
||||
onclick: () => this.activeBeat.val.bakeLoops(),
|
||||
}, [
|
||||
new IconView({
|
||||
iconName: "snowflake",
|
||||
@@ -134,7 +155,7 @@ export default class RootView extends UINode {
|
||||
h("div", {
|
||||
classes: ["root-quick-access-button"],
|
||||
title: "Reset all",
|
||||
onclick: () => this.setMainBeatGroup(RootView.defaultMainBeatGroup()),
|
||||
onclick: () => this.beatStore.resetActiveBeat(),
|
||||
}, [
|
||||
new IconView({
|
||||
iconName: "trash",
|
||||
@@ -147,11 +168,12 @@ export default class RootView extends UINode {
|
||||
private buildSidebar(): HTMLElement {
|
||||
return (
|
||||
h("div", {classes: ["root-sidebar"]}, [
|
||||
this.buildSidebarStripLeft(),
|
||||
h("div", {classes: ["root-settings"]}, [
|
||||
h("h1", {classes: ["root-title"], innerText: this.title}),
|
||||
this.beatSettingsView,
|
||||
]),
|
||||
this.buildSidebarStrip(),
|
||||
this.buildSidebarQuickButtons(),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -161,7 +183,6 @@ export default class RootView extends UINode {
|
||||
h("div", {classes: ["root", "sidebar-visible"]}, [
|
||||
this.buildSidebar(),
|
||||
h("div", {classes: ["root-beat-stage-container"]}, [
|
||||
this.stageTitleBarView,
|
||||
h("div", {classes: ["root-beat-stage"]}, [
|
||||
this.beatView,
|
||||
])
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.stage-title-bar {
|
||||
position: absolute;
|
||||
background-color: var(--color-bg-light);
|
||||
padding: 15px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
color: var(--color-title-light);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stage-title-bar * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stage-title-bar h2 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import "./StageTitleBar.css";
|
||||
import UINode, {h, UINodeOptions} from "@/ui/UINode";
|
||||
import {ISubscription} from "@/Publisher";
|
||||
import Beat, {BeatEvents} from "@/Beat";
|
||||
import ISubscriber from "@/Subscriber";
|
||||
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
|
||||
import DropdownView, {DropdownViewOption} from "@/ui/Widgets/Dropdown/DropdownView";
|
||||
import Ref from "@/Ref";
|
||||
|
||||
export type StageTitleBarViewOptions = UINodeOptions & {
|
||||
beat: Beat,
|
||||
};
|
||||
|
||||
const EventTypeSubscription = [BeatEvents.NameChanged];
|
||||
type EventTypeSubscription = FlatArray<typeof EventTypeSubscription, 1>;
|
||||
|
||||
export default class StageTitleBarView extends UINode implements ISubscriber<EventTypeSubscription> {
|
||||
private sub: ISubscription;
|
||||
private beat: Beat;
|
||||
private title: EditableTextFieldView;
|
||||
private options: Ref<DropdownViewOption[]>;
|
||||
|
||||
constructor(options: StageTitleBarViewOptions) {
|
||||
super(options);
|
||||
this.beat = options.beat;
|
||||
this.sub = options.beat.addSubscriber(this, EventTypeSubscription);
|
||||
this.title = new EditableTextFieldView({
|
||||
initialText: this.beat.getName(),
|
||||
setter: (text) => this.beat.setName(text),
|
||||
noEmpty: true,
|
||||
});
|
||||
this.options = Ref.new<DropdownViewOption[]>([]);
|
||||
}
|
||||
|
||||
notify(publisher: unknown, event: EventTypeSubscription): void {
|
||||
if (event === BeatEvents.NameChanged) {
|
||||
this.title.setText(this.beat.getName());
|
||||
}
|
||||
}
|
||||
|
||||
setBeat(beat: Beat): void {
|
||||
this.sub.unbind();
|
||||
this.beat = beat;
|
||||
this.sub = beat.addSubscriber(this, EventTypeSubscription);
|
||||
this.notify(this, BeatEvents.NameChanged);
|
||||
}
|
||||
|
||||
protected build(): HTMLElement {
|
||||
return h("div", {classes: ["stage-title-bar"]}, [
|
||||
h("h2", {}, [this.title]),
|
||||
new DropdownView({options: this.options})
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const EventTypeSubscriptions = [
|
||||
TrackEvents.LoopLengthChanged,
|
||||
];
|
||||
|
||||
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class TrackView extends UINode implements ISubscriber<EventTypeSubscriptions> {
|
||||
private track!: Track;
|
||||
|
||||
@@ -17,7 +17,7 @@ const EventTypeSubscriptions = [
|
||||
TrackEvents.LoopLengthChanged,
|
||||
TrackEvents.DisplayTypeChanged,
|
||||
];
|
||||
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class TrackSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
|
||||
private track: Track;
|
||||
|
||||
@@ -13,7 +13,7 @@ const EventTypeSubscriptions = [
|
||||
TrackUnitEvent.Off,
|
||||
TrackUnitEvent.TypeChange,
|
||||
];
|
||||
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
|
||||
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
|
||||
|
||||
export default class TrackUnitView extends UINode implements ISubscriber<EventTypeSubscriptions> {
|
||||
private trackUnit: TrackUnit;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import UINode, {h, UINodeOptions} from "@/ui/UINode";
|
||||
import "./Icon.css";
|
||||
import List from "./svgs/list.svg";
|
||||
import ArrowClockwise from "./svgs/arrow-clockwise.svg";
|
||||
import Trash from "./svgs/trash.svg";
|
||||
import Snowflake from "./svgs/snowflake.svg";
|
||||
import Ref from "@/Ref";
|
||||
import List from "assets/svgs/list.svg";
|
||||
import ArrowClockwise from "assets/svgs/arrow-clockwise.svg";
|
||||
import Trash from "assets/svgs/trash.svg";
|
||||
import Snowflake from "assets/svgs/snowflake.svg";
|
||||
|
||||
const IconUrlMap = {
|
||||
arrowClockwise: ArrowClockwise,
|
||||
|
||||
@@ -76,26 +76,26 @@ body {
|
||||
font-family: 'DMSans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(./DMSans-Regular.ttf) format('woff2');
|
||||
src: url(assets/fonts/DMSans-Regular.ttf) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DMSans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(./DMSans-Bold.ttf) format('woff2');
|
||||
src: url(assets/fonts/DMSans-Bold.ttf) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DMSans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url(./DMSans-Italic.ttf) format('woff2');
|
||||
src: url(assets/fonts/DMSans-Italic.ttf) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DMSans';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: url(./DMSans-BoldItalic.ttf) format('woff2');
|
||||
src: url(assets/fonts/DMSans-BoldItalic.ttf) format('woff2');
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["src/*"],
|
||||
"assets/*": ["assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
"include": [
|
||||
"./src/**/*",
|
||||
"./assets/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,8 +44,9 @@ const webpackConfig = {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"assets": path.resolve(__dirname, "./assets"),
|
||||
},
|
||||
extensions: [".tsx", ".ts", ".js"]
|
||||
extensions: [".tsx", ".ts", ".js", ".svg", ".ttf"]
|
||||
},
|
||||
|
||||
output: {
|
||||
|
||||