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 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
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();
}
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);

View File

@@ -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 {

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

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 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,
])

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,
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private track!: Track;

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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: {