Skip to content

Commit 87be950

Browse files
committed
feat(theatre): add theatrejs integration
1 parent 1b5c055 commit 87be950

File tree

19 files changed

+861
-3
lines changed

19 files changed

+861
-3
lines changed

libs/theatre/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
1+
export * from './lib/project';
2+
export * from './lib/sequence';
3+
export * from './lib/sheet';
4+
export * from './lib/sheet-object';
5+
export * from './lib/studio/studio';

libs/theatre/src/lib/project.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,24 @@
1-
export class TheatreProject {}
1+
import { ChangeDetectionStrategy, Component, computed, effect, input } from '@angular/core';
2+
import { getProject, type IProjectConfig, type ISheet } from '@theatre/core';
3+
4+
@Component({
5+
selector: 'theatre-project',
6+
template: `
7+
<ng-content />
8+
`,
9+
changeDetection: ChangeDetectionStrategy.OnPush,
10+
})
11+
export class TheatreProject {
12+
name = input('default-theatre-project');
13+
config = input<IProjectConfig>({});
14+
15+
project = computed(() => getProject(this.name(), this.config()));
16+
sheets: Record<string, [sheet: ISheet, count: number]> = {};
17+
18+
constructor() {
19+
effect(() => {
20+
const project = this.project();
21+
project.ready.then();
22+
});
23+
}
24+
}

libs/theatre/src/lib/sequence.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { computed, Directive, effect, inject, input, model, untracked } from '@angular/core';
2+
import { type ISequence, onChange, val } from '@theatre/core';
3+
import { omit, pick } from 'angular-three';
4+
import { mergeInputs } from 'ngxtension/inject-inputs';
5+
import { TheatreProject } from './project';
6+
import { TheatreSheet } from './sheet';
7+
8+
export interface AttachAudioOptions {
9+
/**
10+
* Either a URL to the audio file (eg "http://localhost:3000/audio.mp3") or an instance of AudioBuffer
11+
*/
12+
source: string | AudioBuffer;
13+
/**
14+
* An optional AudioContext. If not provided, one will be created.
15+
*/
16+
audioContext?: AudioContext;
17+
/**
18+
* An AudioNode to feed the audio into. Will use audioContext.destination if not provided.
19+
*/
20+
destinationNode?: AudioNode;
21+
}
22+
23+
export type TheatreSequenceOptions = Parameters<ISequence['play']>[0] & {
24+
autoplay: boolean;
25+
autopause: boolean;
26+
delay: number;
27+
autoreset?: 'init' | 'destroy' | 'always';
28+
};
29+
30+
const defaultOptions: TheatreSequenceOptions = {
31+
rate: 1,
32+
autoplay: false,
33+
autopause: false,
34+
delay: 0,
35+
};
36+
37+
@Directive({ selector: 'theatre-sheet[sequence]' })
38+
export class TheatreSequence {
39+
options = input(defaultOptions, { alias: 'sequence', transform: mergeInputs(defaultOptions) });
40+
audioOptions = input<AttachAudioOptions | undefined>(undefined, { alias: 'sequenceAudio' });
41+
42+
position = model<number>(0);
43+
playing = model<boolean>(false);
44+
length = model<number>(0);
45+
46+
private playOptions = omit(this.options, ['autoplay', 'autopause', 'delay', 'autoreset']);
47+
private autoplay = pick(this.options, 'autoplay');
48+
private autopause = pick(this.options, 'autopause');
49+
private autoreset = pick(this.options, 'autoreset');
50+
private delay = pick(this.options, 'delay');
51+
52+
private project = inject(TheatreProject);
53+
private sheet = inject(TheatreSheet, { host: true });
54+
sequence = computed(() => this.sheet.sheet().sequence);
55+
56+
constructor() {
57+
effect((onCleanup) => {
58+
const autoplay = untracked(this.autoplay);
59+
if (!autoplay) return;
60+
61+
const delay = untracked(this.delay);
62+
const id = setTimeout(() => {
63+
untracked(() => this.play());
64+
}, delay);
65+
66+
onCleanup(() => {
67+
clearTimeout(id);
68+
});
69+
});
70+
71+
effect((onCleanup) => {
72+
const autopause = untracked(this.autopause);
73+
onCleanup(() => {
74+
if (autopause) {
75+
this.pause();
76+
}
77+
});
78+
});
79+
80+
effect((onCleanup) => {
81+
const autoreset = untracked(this.autoreset);
82+
if (autoreset === 'init' || autoreset === 'always') {
83+
untracked(() => this.reset());
84+
}
85+
86+
onCleanup(() => {
87+
if (autoreset === 'destroy' || autoreset === 'always') {
88+
untracked(() => this.reset());
89+
}
90+
});
91+
});
92+
93+
effect(() => {
94+
const [audioOptions, sequence] = [this.audioOptions(), untracked(this.sequence)];
95+
if (audioOptions) sequence.attachAudio(audioOptions);
96+
});
97+
98+
effect(() => {
99+
const [playOptions, sequence] = [this.playOptions(), untracked(this.sequence)];
100+
const isPlaying = val(sequence.pointer.playing);
101+
if (isPlaying) {
102+
this.pause();
103+
this.play(playOptions);
104+
}
105+
});
106+
107+
effect((onCleanup) => {
108+
const sequence = this.sequence();
109+
110+
const cleanups: Array<() => void> = [];
111+
112+
cleanups.push(
113+
onChange(sequence.pointer.position, (value) => this.position.set(value)),
114+
onChange(sequence.pointer.playing, (value) => this.playing.set(value)),
115+
onChange(sequence.pointer.length, (value) => this.length.set(value)),
116+
);
117+
118+
onCleanup(() => {
119+
cleanups.forEach((cleanup) => cleanup());
120+
});
121+
});
122+
}
123+
124+
pause() {
125+
const sequence = this.sequence();
126+
sequence.pause();
127+
}
128+
129+
play(options: Parameters<ISequence['play']>[0] = {}) {
130+
const sequence = this.sequence();
131+
const project = this.project.project();
132+
133+
project.ready.then(() => {
134+
sequence.play({ ...this.playOptions(), ...options });
135+
});
136+
}
137+
138+
reset() {
139+
const sequence = this.sequence();
140+
const isPlaying = val(sequence.pointer.playing);
141+
sequence.position = 0;
142+
if (isPlaying) this.play();
143+
}
144+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { TheatreSheetObject as Impl } from './sheet-object';
2+
import { TheatreSheetObjectSync } from './sync';
3+
import { TheatreSheetObjectTransform } from './transform';
4+
5+
export { TheatreSheetObject as TheatreSheetObjectImpl } from './sheet-object';
6+
export * from './sync';
7+
export * from './transform';
8+
9+
export const TheatreSheetObject = [Impl, TheatreSheetObjectTransform, TheatreSheetObjectSync];
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
booleanAttribute,
3+
computed,
4+
DestroyRef,
5+
Directive,
6+
effect,
7+
inject,
8+
input,
9+
linkedSignal,
10+
model,
11+
TemplateRef,
12+
untracked,
13+
ViewContainerRef,
14+
} from '@angular/core';
15+
import { UnknownShorthandCompoundProps } from '@theatre/core';
16+
import { injectStore } from 'angular-three';
17+
import { TheatreSheet } from '../sheet';
18+
import { THEATRE_STUDIO } from '../studio/studio-token';
19+
20+
@Directive({ selector: 'ng-template[sheetObject]' })
21+
export class TheatreSheetObject {
22+
key = input.required<string>({ alias: 'sheetObject' });
23+
props = input<UnknownShorthandCompoundProps>({});
24+
detach = input(false, { transform: booleanAttribute });
25+
selected = model<boolean>(false);
26+
27+
private templateRef = inject(TemplateRef);
28+
private vcr = inject(ViewContainerRef);
29+
private sheet = inject(TheatreSheet);
30+
private studio = inject(THEATRE_STUDIO, { optional: true });
31+
private store = injectStore();
32+
33+
private originalSheetObject = computed(() => {
34+
const sheet = this.sheet.sheet();
35+
return sheet.object(this.key(), untracked(this.props), { reconfigure: true });
36+
});
37+
sheetObject = linkedSignal(this.originalSheetObject);
38+
values = linkedSignal(() => this.sheetObject().value);
39+
40+
private detached = false;
41+
private aggregatedProps: UnknownShorthandCompoundProps = {};
42+
43+
constructor() {
44+
effect(() => {
45+
this.aggregatedProps = { ...this.aggregatedProps, ...this.props() };
46+
});
47+
48+
effect((onCleanup) => {
49+
const sheetObject = this.sheetObject();
50+
const cleanup = sheetObject.onValuesChange((newValues) => {
51+
this.values.set(newValues);
52+
this.store.snapshot.invalidate();
53+
});
54+
onCleanup(cleanup);
55+
});
56+
57+
effect((onCleanup) => {
58+
const studio = this.studio?.();
59+
if (!studio) return;
60+
61+
const sheetObject = this.sheetObject();
62+
const cleanup = studio.onSelectionChange((selection) => {
63+
this.selected.set(selection.includes(sheetObject));
64+
});
65+
onCleanup(cleanup);
66+
});
67+
68+
effect((onCleanup) => {
69+
const view = this.vcr.createEmbeddedView(this.templateRef, {
70+
select: this.select.bind(this),
71+
deselect: this.deselect.bind(this),
72+
sheetObject: this.sheetObject.asReadonly(),
73+
values: this.values.asReadonly(),
74+
});
75+
view.detectChanges();
76+
onCleanup(() => {
77+
view.destroy();
78+
});
79+
});
80+
81+
inject(DestroyRef).onDestroy(() => {
82+
if (this.detach()) {
83+
this.detached = true;
84+
this.sheet.sheet().detachObject(this.key());
85+
}
86+
});
87+
}
88+
89+
update() {
90+
if (this.detached) return;
91+
92+
const [sheet, key] = [untracked(this.sheet.sheet), untracked(this.key)];
93+
sheet.detachObject(key);
94+
this.sheetObject.set(sheet.object(key, this.aggregatedProps, { reconfigure: true }));
95+
}
96+
97+
addProps(props: UnknownShorthandCompoundProps) {
98+
this.aggregatedProps = { ...this.aggregatedProps, ...props };
99+
this.update();
100+
}
101+
102+
removeProps(props: string[]) {
103+
const [detach, sheet, key] = [untracked(this.detach), untracked(this.sheet.sheet), untracked(this.key)];
104+
105+
// remove props from sheet object
106+
props.forEach((prop) => {
107+
delete this.aggregatedProps[prop];
108+
});
109+
110+
// if there are no more props, detach sheet object
111+
if (Object.keys(this.aggregatedProps).length === 0) {
112+
// detach sheet object
113+
if (detach) {
114+
sheet.detachObject(key);
115+
}
116+
} else {
117+
// update sheet object (reconfigure)
118+
this.update();
119+
}
120+
}
121+
122+
select() {
123+
const studio = this.studio?.();
124+
if (!studio) return;
125+
studio.setSelection([this.sheetObject()]);
126+
}
127+
128+
deselect() {
129+
const studio = this.studio?.();
130+
if (!studio) return;
131+
132+
if (studio.selection.includes(this.sheetObject())) {
133+
studio.setSelection([]);
134+
}
135+
}
136+
137+
static ngTemplateContextGuard(
138+
_: TheatreSheetObject,
139+
ctx: unknown,
140+
): ctx is {
141+
select: TheatreSheetObject['select'];
142+
deselect: TheatreSheetObject['deselect'];
143+
sheetObject: ReturnType<TheatreSheetObject['sheetObject']['asReadonly']>;
144+
values: ReturnType<TheatreSheetObject['values']['asReadonly']>;
145+
} {
146+
return true;
147+
}
148+
}

0 commit comments

Comments
 (0)