feat: auto-save and multiple tracks

This commit is contained in:
Daniel Ledda
2022-04-17 14:08:38 +02:00
parent 77b5e25e64
commit 1861403f24
31 changed files with 498 additions and 201 deletions

View File

Before

Width:  |  Height:  |  Size: 352 B

After

Width:  |  Height:  |  Size: 352 B

View File

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 344 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 582 B

View File

@@ -2,6 +2,7 @@ import Track, {TrackEvents, TrackInitOptions} from "@/Track";
import {IPublisher, Publisher} from "@/Publisher"; import {IPublisher, Publisher} from "@/Publisher";
import ISubscriber from "@/Subscriber"; import ISubscriber from "@/Subscriber";
import {greatestCommonDivisor, isPosInt} from "@/utils"; import {greatestCommonDivisor, isPosInt} from "@/utils";
import Ref from "@/Ref";
type BeatGroupInitOptions = { type BeatGroupInitOptions = {
barCount: number; barCount: number;
@@ -13,6 +14,17 @@ type BeatGroupInitOptions = {
name?: string, 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 { export const enum BeatEvents {
TrackOrderChanged="be-0", TrackOrderChanged="be-0",
TrackListChanged="be-1", TrackListChanged="be-1",
@@ -22,14 +34,17 @@ export const enum BeatEvents {
LockingChanged="be-5", LockingChanged="be-5",
GlobalLoopLengthChanged="be-5", GlobalLoopLengthChanged="be-5",
GlobalDisplayTypeChanged="be-6", GlobalDisplayTypeChanged="be-6",
NameChanged="be-7", DeepChange="be-7",
} }
type EventTypeSubscriptions = const EventTypeSubscriptions = [
| TrackEvents.LoopLengthChanged TrackEvents.LoopLengthChanged,
| TrackEvents.DisplayTypeChanged TrackEvents.DisplayTypeChanged,
| TrackEvents.WantsRemoval TrackEvents.WantsRemoval,
| TrackEvents.Baked; TrackEvents.DeepChange,
TrackEvents.Baked,
];
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTypeSubscriptions> { export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTypeSubscriptions> {
private static globalCounter = 0; private static globalCounter = 0;
@@ -41,14 +56,14 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
private globalIsLooping: boolean; private globalIsLooping: boolean;
private useAutoBeatLength: boolean; private useAutoBeatLength: boolean;
private barSettingsLocked = false; private barSettingsLocked = false;
private name: string; private name: Ref<string>;
constructor(options?: BeatGroupInitOptions) { constructor(options?: BeatGroupInitOptions) {
Beat.globalCounter++; Beat.globalCounter++;
if (options?.name) { if (options?.name) {
this.name = options.name; this.name = Ref.new<string>(options.name);
} else { } else {
this.name = `Pattern ${Beat.globalCounter}`; this.name = Ref.new<string>(`Pattern ${Beat.globalCounter}`);
} }
if (options?.tracks) { if (options?.tracks) {
for (const trackOptions of 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; 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 { notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) { switch (event) {
case TrackEvents.LoopLengthChanged: case TrackEvents.LoopLengthChanged:
@@ -75,9 +106,10 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
this.setIsUsingAutoBeatLength(false); this.setIsUsingAutoBeatLength(false);
break; 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); 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; return this.tracks.indexOf(this.getTrackByKey(trackKey)) < this.tracks.length - 1;
} }
addTrack(options?: TrackInitOptions): Track { addTrack(track: Track): void;
options = { addTrack(options?: TrackInitOptions): Track;
timeSig: { addTrack(optionsOrTrack?: Track | TrackInitOptions): Track | void {
up: this.timeSigUp, let newTrack: Track;
down: 4, if (optionsOrTrack instanceof Track) {
}, newTrack = optionsOrTrack;
bars: this.barCount, } else {
isLooping: this.globalIsLooping, optionsOrTrack = {
loopLength: this.globalLoopLength, timeSig: {
...options up: this.timeSigUp,
}; down: 4,
const newTrack = new Track(options); },
bars: this.barCount,
isLooping: this.globalIsLooping,
loopLength: this.globalLoopLength,
...optionsOrTrack
};
newTrack = new Track(optionsOrTrack);
}
this.tracks.push(newTrack); this.tracks.push(newTrack);
newTrack.addSubscriber(this, [ newTrack.addSubscriber(this, EventTypeSubscriptions);
TrackEvents.LoopLengthChanged,
TrackEvents.WantsRemoval,
TrackEvents.DisplayTypeChanged,
TrackEvents.Baked,
]);
this.publisher.notifySubs(BeatEvents.TrackListChanged); this.publisher.notifySubs(BeatEvents.TrackListChanged);
return newTrack; return newTrack;
} }
@@ -295,11 +329,33 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
} }
setName(newName: string): void { setName(newName: string): void {
this.name = newName; this.name.val = newName;
this.publisher.notifySubs(BeatEvents.NameChanged);
} }
getName(): string { getName(): Ref<string> {
return this.name; 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
View 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);
}
}
}
}

View File

@@ -42,12 +42,12 @@ export class Publisher<EventType extends LEvent, PublisherType> implements IPubl
this.subscribers = new Map(); this.subscribers = new Map();
} }
addSubscriber(subscriber: ISubscriber<EventType>, subscribeTo: EventType | EventType[]): ISubscription { addSubscriber(subscriber: ISubscriber<EventType>, subscribeTo: EventType | Readonly<EventType[]>): ISubscription {
let eventTypes: EventType[] = []; let eventTypes: EventType[] = [];
if (!Array.isArray(subscribeTo)) { if (typeof subscribeTo === "string") {
eventTypes.push(subscribeTo); eventTypes.push(subscribeTo);
} else { } else {
eventTypes = subscribeTo; eventTypes = subscribeTo.slice();
} }
for (const key of eventTypes) { for (const key of eventTypes) {
this.getSubscribers(key).push(subscriber); this.getSubscribers(key).push(subscriber);

View File

@@ -22,6 +22,7 @@ type AllowedRef = { toString(): string } | string | null;
export default class Ref<T extends AllowedRef = Stringable> { export default class Ref<T extends AllowedRef = Stringable> {
private watchers: Array<(newVal: T) => void> | null = null; private watchers: Array<(newVal: T) => void> | null = null;
private afterWatchers: Array<(newVal: T) => void> | null = null;
private value: T; private value: T;
private asString?: string; private asString?: string;
private isString: boolean; private isString: boolean;
@@ -39,21 +40,38 @@ export default class Ref<T extends AllowedRef = Stringable> {
} }
} }
watch(watcher: (newVal: T) => void): ISubscription { watch(watcher: (newVal: T) => void, after?: boolean): ISubscription {
if (this.watchers === null) { if (after) {
this.watchers = []; 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, !!after));
return new RefSubscription(() => this.unbind(watcher));
} }
private unbind(watcher: (newVal: T) => void): void { private unbind(watcher: (newVal: T) => void, after: boolean): void {
if (!this.watchers) { if (after) {
return; if (!this.afterWatchers) {
} return;
const index = this.watchers.indexOf(watcher); }
if (index !== -1) { const index = this.afterWatchers.indexOf(watcher);
this.watchers.splice(index, 1); 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) { set val(val: T) {
this.watchers?.forEach(watcher => watcher(val)); this.watchers?.forEach(watcher => watcher(val));
this.value = val; this.value = val;
this.afterWatchers?.forEach(watcher => watcher(val));
} }
toString(): string { toString(): string {

View File

@@ -1,10 +0,0 @@
import Beat from "@/Beat";
export default class Store {
private beats: Beat[];
constructor() {
this.beats = [];
}
}

View File

@@ -1,4 +1,4 @@
import TrackUnit from "@/TrackUnit"; import TrackUnit, {TrackUnitType} from "@/TrackUnit";
import {IPublisher, Publisher} from "@/Publisher"; import {IPublisher, Publisher} from "@/Publisher";
import ISubscriber from "@/Subscriber"; import ISubscriber from "@/Subscriber";
import {isPosInt} from "@/utils"; import {isPosInt} from "@/utils";
@@ -22,6 +22,20 @@ export const enum TrackEvents {
LoopLengthChanged="be-4", LoopLengthChanged="be-4",
WantsRemoval="be-5", WantsRemoval="be-5",
Baked="be-6", 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> { export default class Track implements IPublisher<TrackEvents> {
@@ -46,6 +60,30 @@ export default class Track implements IPublisher<TrackEvents> {
this.looping = options?.isLooping ?? false; 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 { setLoopLength(loopLength: number): void {
if (!isPosInt(loopLength) || loopLength < 2) { if (!isPosInt(loopLength) || loopLength < 2) {
loopLength = this.loopLength; loopLength = this.loopLength;
@@ -105,7 +143,9 @@ export default class Track implements IPublisher<TrackEvents> {
} else if (newBarCount > this.unitRecord.length) { } else if (newBarCount > this.unitRecord.length) {
const barsToAdd = newBarCount - this.unitRecord.length; const barsToAdd = newBarCount - this.unitRecord.length;
for (let i = 0; i < barsToAdd; i++) { 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); 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);
}
} }

View File

@@ -1,5 +1,6 @@
import {IPublisher, Publisher} from "./Publisher"; import {IPublisher, Publisher} from "./Publisher";
import ISubscriber from "./Subscriber"; import ISubscriber from "./Subscriber";
import Track from "@/Track";
export const enum TrackUnitType { export const enum TrackUnitType {
Normal="tut-0", 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 publisher: Publisher<TrackUnitEvent, TrackUnit> = new Publisher<TrackUnitEvent, TrackUnit>(this);
private on = false; private on = false;
private typeIndex = 0; private typeIndex = 0;
private parent: Track;
constructor(on = false, type = TrackUnitType.Normal) { constructor(options: {
this.on = on; on?: boolean,
this.setType(type); 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 } { addSubscriber(subscriber: ISubscriber<TrackUnitEvent>, eventType: TrackUnitEvent[]): { unbind: () => void } {
@@ -44,16 +51,19 @@ export default class TrackUnit implements IPublisher<TrackUnitEvent> {
} else { } else {
this.publisher.notifySubs(TrackUnitEvent.Off); this.publisher.notifySubs(TrackUnitEvent.Off);
} }
this.parent.alertDeepChange();
} }
setOn(on: boolean): void { setOn(on: boolean): void {
this.on = on; this.on = on;
this.publisher.notifySubs(this.on ? TrackUnitEvent.On : TrackUnitEvent.Off); this.publisher.notifySubs(this.on ? TrackUnitEvent.On : TrackUnitEvent.Off);
this.parent.alertDeepChange();
} }
setType(type: TrackUnitType): void { setType(type: TrackUnitType): void {
this.typeIndex = TrackUnit.TypeRotation.indexOf(type); this.typeIndex = TrackUnit.TypeRotation.indexOf(type);
this.publisher.notifySubs(TrackUnitEvent.TypeChange); this.publisher.notifySubs(TrackUnitEvent.TypeChange);
this.parent.alertDeepChange();
} }
getType(): TrackUnitType { getType(): TrackUnitType {
@@ -67,6 +77,7 @@ export default class TrackUnit implements IPublisher<TrackUnitEvent> {
this.typeIndex += 1; this.typeIndex += 1;
} }
this.publisher.notifySubs(TrackUnitEvent.TypeChange); this.publisher.notifySubs(TrackUnitEvent.TypeChange);
this.parent.alertDeepChange();
} }
isOn(): boolean { isOn(): boolean {

View File

@@ -7,9 +7,28 @@
flex-direction: column; flex-direction: column;
} }
.vertical-mode .beat {
align-items: center;
}
.vertical-mode .beat { .vertical-mode .beat {
height: inherit; height: inherit;
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; 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 {
}

View File

@@ -4,21 +4,21 @@ import TrackView from "@/ui/Track/TrackView";
import "./Beat.css"; import "./Beat.css";
import ISubscriber from "@/Subscriber"; import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher"; import {ISubscription} from "@/Publisher";
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
export type BeatUINodeOptions = UINodeOptions & { export type BeatUINodeOptions = UINodeOptions & {
title: string,
beat: Beat, beat: Beat,
orientation?: "horizontal" | "vertical", orientation?: "horizontal" | "vertical",
}; };
const EventTypeSubscriptions = [ const EventTypeSubscriptions = [
BeatEvents.TrackListChanged BeatEvents.TrackListChanged,
]; ] as const;
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class BeatView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class BeatView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private title: string;
private beat: Beat; private beat: Beat;
private title: EditableTextFieldView;
private trackViews: TrackView[] = []; private trackViews: TrackView[] = [];
private currentOrientation: "vertical" | "horizontal"; private currentOrientation: "vertical" | "horizontal";
private subscription: ISubscription; private subscription: ISubscription;
@@ -26,9 +26,13 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
constructor(options: BeatUINodeOptions) { constructor(options: BeatUINodeOptions) {
super(options); super(options);
this.beat = options.beat; this.beat = options.beat;
this.title = options.title;
this.currentOrientation = options.orientation ?? "horizontal"; this.currentOrientation = options.orientation ?? "horizontal";
this.subscription = this.beat.addSubscriber(this, EventTypeSubscriptions); 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(); this.setupTrackViews();
} }
@@ -69,19 +73,37 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
this.redraw(); this.redraw();
} }
setBeat(newBeat: Beat): void { private onNewBeat(): void {
this.beat = newBeat; this.beat.getName().watch((newVal) => {
this.subscription.unbind(); this.title.setText(newVal);
this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged); });
this.title.setText(this.beat.getName().val);
EventTypeSubscriptions.forEach(event => this.notify(this, event));
this.setupTrackViews(); this.setupTrackViews();
this.redraw(); this.redraw();
} }
setBeat(newBeat: Beat): void {
this.beat = newBeat;
this.subscription.unbind();
this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged);
this.onNewBeat();
}
build(): HTMLDivElement { build(): HTMLDivElement {
return h("div", { return h("div", {
classes: ["beat"], className: "beat",
},[ },[
...this.trackViews h("h2", {
className: "beat-title",
}, [
this.title,
]),
h("div", {
className: "beat-track-container",
}, [
...this.trackViews,
]),
]); ]);
} }
} }

View File

@@ -19,7 +19,7 @@ const EventTypeSubscriptions = [
BeatEvents.LockingChanged, BeatEvents.LockingChanged,
BeatEvents.AutoBeatSettingsChanged, BeatEvents.AutoBeatSettingsChanged,
]; ];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class BeatSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class BeatSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private beat: Beat; private beat: Beat;

View File

@@ -9,6 +9,7 @@
--color-ui-neutral-dark-hover: #a1a1a1; --color-ui-neutral-dark-hover: #a1a1a1;
--color-ui-neutral-dark-active: #c1c1c1; --color-ui-neutral-dark-active: #c1c1c1;
--color-bg-light: #464646; --color-bg-light: #464646;
--color-bg-medium: #323232;
--color-bg-dark: #282828; --color-bg-dark: #282828;
--color-p-light: #fafafa; --color-p-light: #fafafa;
--color-p-light-hover: #fafafa; --color-p-light-hover: #fafafa;
@@ -45,7 +46,6 @@
.root-settings { .root-settings {
z-index: 1; z-index: 1;
width: 28em; width: 28em;
padding: 0 0 0 2em;
background-color: var(--color-bg-light); background-color: var(--color-bg-light);
overflow: scroll; overflow: scroll;
display: inline-block; display: inline-block;
@@ -97,7 +97,7 @@
} }
.vertical-mode .root-beat-stage { .vertical-mode .root-beat-stage {
margin: 5em auto auto; margin: auto auto;
padding-left: 3em; padding-left: 3em;
height: 100vh; height: 100vh;
} }
@@ -106,6 +106,39 @@
max-width: calc(100vw - 30em); 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) { @media screen and (max-width: 900px) {
.sidebar-visible .root-sidebar { .sidebar-visible .root-sidebar {
left: 0; left: 0;

View File

@@ -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 BeatView from "@/ui/Beat/BeatView";
import Beat from "@/Beat"; import Beat from "@/Beat";
import "./Root.css"; import "./Root.css";
import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView"; import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView";
import IconView from "@/ui/Widgets/Icon/IconView"; import IconView from "@/ui/Widgets/Icon/IconView";
import StageTitleBarView from "@/ui/StageTitleBar/StageTitleBarView";
import Ref from "@/Ref"; import Ref from "@/Ref";
import BeatStore from "@/BeatStore";
export type RootUINodeOptions = UINodeOptions & { export type RootUINodeOptions = UINodeOptions & {
title: string, title: string,
@@ -16,24 +16,34 @@ export type RootUINodeOptions = UINodeOptions & {
export default class RootView extends UINode { export default class RootView extends UINode {
private title: string; private title: string;
private beatView: BeatView; private beatView: BeatView;
private focusedBeat: Beat; private beatStore: BeatStore;
private activeBeat: Ref<Beat>;
private beatSettingsView: BeatSettingsView; private beatSettingsView: BeatSettingsView;
private currentOrientation: "horizontal" | "vertical"; private currentOrientation: "horizontal" | "vertical";
private stageTitleBarView: StageTitleBarView;
private showHideSidebarButton: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null); private showHideSidebarButton: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
private sidebarActive = true; private sidebarActive = true;
private sidebarLeftTabs: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
constructor(options: RootUINodeOptions) { constructor(options: RootUINodeOptions) {
super(options); super(options);
this.beatStore = new BeatStore({
loadFromLocalStorage: true,
autoSave: true,
});
this.currentOrientation = options.orientation ?? "horizontal"; 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({ this.beatView = new BeatView({
title: options.title, beat: this.activeBeat.val,
beat: this.focusedBeat,
orientation: this.currentOrientation, orientation: this.currentOrientation,
}); });
this.stageTitleBarView = new StageTitleBarView({beat: this.focusedBeat}); this.beatStore.onBeatChanges(() => {
this.beatSettingsView = new BeatSettingsView({beat: this.focusedBeat}); this.sidebarLeftTabs.val?.replaceChildren(...this.buildTabs());
});
this.beatSettingsView = new BeatSettingsView({beat: this.activeBeat.val});
this.title = options.title; this.title = options.title;
this.setOrientation(this.currentOrientation); this.setOrientation(this.currentOrientation);
this.openSidebarForDesktop(); 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 { toggleSidebar(): void {
this.sidebarActive = !this.sidebarActive; this.sidebarActive = !this.sidebarActive;
this.showHideSidebarButton.val!.title = this.sidebarText(); this.showHideSidebarButton.val!.title = this.sidebarText();
@@ -95,8 +84,40 @@ export default class RootView extends UINode {
return `${this.sidebarActive ? "Hide" : "Show"} sidebar`; 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", { return h("div", {
classes: ["root-sidebar-toggle"], classes: ["root-sidebar-toggle"],
}, [ }, [
@@ -124,7 +145,7 @@ export default class RootView extends UINode {
h("div", { h("div", {
classes: ["root-quick-access-button"], classes: ["root-quick-access-button"],
title: "Bake all tracks", title: "Bake all tracks",
onclick: () => this.focusedBeat.bakeLoops(), onclick: () => this.activeBeat.val.bakeLoops(),
}, [ }, [
new IconView({ new IconView({
iconName: "snowflake", iconName: "snowflake",
@@ -134,7 +155,7 @@ export default class RootView extends UINode {
h("div", { h("div", {
classes: ["root-quick-access-button"], classes: ["root-quick-access-button"],
title: "Reset all", title: "Reset all",
onclick: () => this.setMainBeatGroup(RootView.defaultMainBeatGroup()), onclick: () => this.beatStore.resetActiveBeat(),
}, [ }, [
new IconView({ new IconView({
iconName: "trash", iconName: "trash",
@@ -147,11 +168,12 @@ export default class RootView extends UINode {
private buildSidebar(): HTMLElement { private buildSidebar(): HTMLElement {
return ( return (
h("div", {classes: ["root-sidebar"]}, [ h("div", {classes: ["root-sidebar"]}, [
this.buildSidebarStripLeft(),
h("div", {classes: ["root-settings"]}, [ h("div", {classes: ["root-settings"]}, [
h("h1", {classes: ["root-title"], innerText: this.title}), h("h1", {classes: ["root-title"], innerText: this.title}),
this.beatSettingsView, this.beatSettingsView,
]), ]),
this.buildSidebarStrip(), this.buildSidebarQuickButtons(),
]) ])
); );
} }
@@ -161,7 +183,6 @@ export default class RootView extends UINode {
h("div", {classes: ["root", "sidebar-visible"]}, [ h("div", {classes: ["root", "sidebar-visible"]}, [
this.buildSidebar(), this.buildSidebar(),
h("div", {classes: ["root-beat-stage-container"]}, [ h("div", {classes: ["root-beat-stage-container"]}, [
this.stageTitleBarView,
h("div", {classes: ["root-beat-stage"]}, [ h("div", {classes: ["root-beat-stage"]}, [
this.beatView, this.beatView,
]) ])

View File

@@ -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;
}

View File

@@ -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})
]);
}
}

View File

@@ -18,7 +18,7 @@ const EventTypeSubscriptions = [
TrackEvents.LoopLengthChanged, TrackEvents.LoopLengthChanged,
]; ];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class TrackView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private track!: Track; private track!: Track;

View File

@@ -17,7 +17,7 @@ const EventTypeSubscriptions = [
TrackEvents.LoopLengthChanged, TrackEvents.LoopLengthChanged,
TrackEvents.DisplayTypeChanged, TrackEvents.DisplayTypeChanged,
]; ];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class TrackSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private track: Track; private track: Track;

View File

@@ -13,7 +13,7 @@ const EventTypeSubscriptions = [
TrackUnitEvent.Off, TrackUnitEvent.Off,
TrackUnitEvent.TypeChange, TrackUnitEvent.TypeChange,
]; ];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackUnitView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class TrackUnitView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private trackUnit: TrackUnit; private trackUnit: TrackUnit;

View File

@@ -1,10 +1,9 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode"; import UINode, {h, UINodeOptions} from "@/ui/UINode";
import "./Icon.css"; import "./Icon.css";
import List from "./svgs/list.svg"; import List from "assets/svgs/list.svg";
import ArrowClockwise from "./svgs/arrow-clockwise.svg"; import ArrowClockwise from "assets/svgs/arrow-clockwise.svg";
import Trash from "./svgs/trash.svg"; import Trash from "assets/svgs/trash.svg";
import Snowflake from "./svgs/snowflake.svg"; import Snowflake from "assets/svgs/snowflake.svg";
import Ref from "@/Ref";
const IconUrlMap = { const IconUrlMap = {
arrowClockwise: ArrowClockwise, arrowClockwise: ArrowClockwise,

View File

@@ -76,26 +76,26 @@ body {
font-family: 'DMSans'; font-family: 'DMSans';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(./DMSans-Regular.ttf) format('woff2'); src: url(assets/fonts/DMSans-Regular.ttf) format('woff2');
} }
@font-face { @font-face {
font-family: 'DMSans'; font-family: 'DMSans';
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
src: url(./DMSans-Bold.ttf) format('woff2'); src: url(assets/fonts/DMSans-Bold.ttf) format('woff2');
} }
@font-face { @font-face {
font-family: 'DMSans'; font-family: 'DMSans';
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
src: url(./DMSans-Italic.ttf) format('woff2'); src: url(assets/fonts/DMSans-Italic.ttf) format('woff2');
} }
@font-face { @font-face {
font-family: 'DMSans'; font-family: 'DMSans';
font-style: italic; font-style: italic;
font-weight: 600; font-weight: 600;
src: url(./DMSans-BoldItalic.ttf) format('woff2'); src: url(assets/fonts/DMSans-BoldItalic.ttf) format('woff2');
} }

View File

@@ -10,8 +10,12 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"],
"assets/*": ["assets/*"]
} }
}, },
"include": ["./src/**/*"] "include": [
"./src/**/*",
"./assets/**/*"
]
} }

View File

@@ -44,8 +44,9 @@ const webpackConfig = {
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
"assets": path.resolve(__dirname, "./assets"),
}, },
extensions: [".tsx", ".ts", ".js"] extensions: [".tsx", ".ts", ".js", ".svg", ".ttf"]
}, },
output: { output: {