Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion packages/app/studio/src/service/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
/**
* Subscribes to the session value and immediately dispatches the current
* state to the observer.
*
* @param observer - Receives the current session and subsequent updates.
* @returns Subscription to stop receiving updates.
*/
catchupAndSubscribe(observer: Observer<ObservableValue<Option<ProjectSession>>>): Terminable {
observer(this)
Expand All @@ -65,6 +68,8 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
/**
* Save the current project. Unsaved sessions trigger {@link saveAs} to
* gather metadata.
*
* @returns Promise that resolves once the project has been saved.
*/
async save(): Promise<void> {
return this.#session.getValue()
Expand All @@ -74,6 +79,8 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
/**
* Save the current session under a new name, prompting the user for
* metadata.
*
* @returns Promise that resolves once the project has been saved.
*/
async saveAs(): Promise<void> {
return this.#session.getValue().ifSome(async session => {
Expand All @@ -87,7 +94,11 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
})
}

/** Open a dialog listing available projects and load the chosen one. */
/**
* Open a dialog listing available projects and load the chosen one.
*
* @returns Promise resolving when the chosen project has been loaded.
*/
async browse(): Promise<void> {
const {status, value} = await Promises.tryCatch(ProjectDialogs.showBrowseDialog(this.#service))
if (status === "resolved") {
Expand All @@ -101,6 +112,7 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
*
* @param uuid identifier of the project to load.
* @param meta previously stored metadata.
* @returns Promise resolved once the project is loaded.
*/
async loadExisting(uuid: UUID.Format, meta: ProjectMeta) {
console.debug(UUID.toString(uuid))
Expand All @@ -113,6 +125,7 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
* Load a project template shipped with the application.
*
* @param name template identifier without extension.
* @returns Promise resolved when the template has been loaded.
*/
async loadTemplate(name: string): Promise<unknown> {
console.debug(`load '${name}'`)
Expand All @@ -135,6 +148,8 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
/**
* Export the current project and its samples as a bundle and allow the user
* to save it as a file.
*
* @returns Promise resolving once the export action has completed.
*/
async exportZip(): Promise<void> {
return this.#session.getValue().ifSome(async session => {
Expand Down Expand Up @@ -164,6 +179,8 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess

/**
* Import a previously exported project bundle from disk.
*
* @returns Promise resolving when the bundle has been imported.
*/
async importZip(): Promise<void> {
try {
Expand All @@ -179,6 +196,8 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess

/**
* Save the raw project file to disk without bundling samples.
*
* @returns Promise resolving when the file has been saved.
*/
async saveFile(): Promise<void> {
this.#session.getValue().ifSome(async session => {
Expand All @@ -198,6 +217,8 @@ export class SessionService implements MutableObservableValue<Option<ProjectSess
}
/**
* Load a raw project file from disk and start a new session.
*
* @returns Promise resolving when the file has been loaded.
*/
async loadFile(): Promise<void> {
try {
Expand Down
23 changes: 23 additions & 0 deletions packages/app/studio/src/service/StudioService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,15 @@ export class StudioService implements ProjectEnv {
}
}

/** Start a new session from an existing project. */
fromProject(project: Project, name: string): void {this.sessionService.fromProject(project, name)}

/**
* Execute a procedure only if a project session is available.
*
* @param procedure - Callback invoked with the active project.
* @returns Option of the callback result.
*/
runIfProject<R>(procedure: Func<Project, R>): Option<R> {
return this.sessionService.getValue().map(({project}) => procedure(project))
}
Expand All @@ -448,17 +455,33 @@ export class StudioService implements ProjectEnv {
})
}

/**
* Switch the workspace to another screen.
*
* @param key - Identifier of the screen or `null` for the default.
*/
switchScreen(key: Nullable<Workspace.ScreenKeys>): void {
this.layout.screen.setValue(key)
RouteLocation.get().navigateTo("/")
}

/**
* Register a factory used to create a footer label component.
*
* @param factory - Provider creating footer labels on demand.
*/
registerFooter(factory: Provider<FooterLabel>): void {
this.#factoryFooterLabel = Option.wrap(factory)
}

/**
* Accessor for the registered footer label factory.
*/
factoryFooterLabel(): Option<Provider<FooterLabel>> {return this.#factoryFooterLabel}

/**
* Notify listeners to reset waveform peaks across the UI.
*/
resetPeaks(): void {this.#signals.notify({type: "reset-peaks"})}

#startAudioWorklet(terminator: Terminator, project: Project): void {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/studio/src/service/StudioSignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {Sample} from "@opendaw/studio-adapters"
* These complement the {@link SessionService} lifecycle and other session
* oriented services.
*
* @remarks
* The set is intentionally small but can grow as new UI elements are
* introduced.
*
* ```mermaid
* classDiagram
* class StudioSignal
Expand All @@ -24,4 +28,4 @@ export type StudioSignal =
} | {
/** Delete a project identified by its metadata. */
type: "delete-project", meta: ProjectMeta
}
}
2 changes: 2 additions & 0 deletions packages/app/studio/src/service/SyncLogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export namespace SyncLogService {
* Start a new SyncLog and attach it to the current project.
*
* @param service - Studio service providing the project context.
* @returns Promise resolving when the SyncLog is ready.
*/
export const start = async (service: StudioService) => {
if (!isDefined(window.showSaveFilePicker)) {return}
Expand All @@ -43,6 +44,7 @@ export namespace SyncLogService {
* Append commits to an existing SyncLog file selected by the user.
*
* @param service - Studio service providing the project context.
* @returns Promise resolving when all commits have been appended.
*/
export const append = async (service: StudioService) => {
const openResult = await Promises.tryCatch(window.showOpenFilePicker(FilePickerAcceptTypes.ProjectSyncLog))
Expand Down
7 changes: 6 additions & 1 deletion packages/app/studio/src/ui/components/ControlIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ export interface ControlIndicatorProps {
parameter: AutomatableParameterFieldAdapter
}

/** Adds an automation indicator to its children. */
/**
* Adds an automation indicator to its children.
*
* @param children - Elements to decorate with the indicator.
* @returns Wrapped element reflecting automation state.
*/
export const ControlIndicator = ({lifecycle, parameter}: ControlIndicatorProps, children: JsxValue) => {
const element: HTMLElement = <Group>{children}</Group>
lifecycle.own(parameter.catchupAndSubscribeControlSources({
Expand Down
6 changes: 5 additions & 1 deletion packages/app/studio/src/ui/components/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export interface NumberInputProps {
step?: number
}

/** Editable numeric field with keyboard controls. */
/**
* Editable numeric field with keyboard controls.
*
* @returns Element representing the numeric input.
*/
export const NumberInput = ({lifecycle, model, negativeWarning, className, maxChars, mapper, step}: NumberInputProps) => {
step ??= 1.0
maxChars ??= 3
Expand Down
15 changes: 15 additions & 0 deletions packages/docs/docs-dev/architecture/state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# State

The Studio maintains its user interface and engine state through a collection
of observable values and service classes. Core helpers from the
`@opendaw/lib-std` package provide `Observable` and `MutableObservableValue`
primitives that allow components to react to changes without tight coupling.

High level services such as `SessionService` and `StudioService` expose these
observables to coordinate project loading, transport control and other
application features. Components subscribe to these values and update the UI or
audio engine in response.

For commit history and synchronisation details see the
[SyncLog architecture](sync-log.md).

1 change: 1 addition & 0 deletions packages/docs/docs-user/ui-tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ For internal tooling and diagnostics see:
- [Components](../docs-dev/ui/pages/components.md)
- [Advanced Components](../docs-dev/ui/components/advanced.md)
- [Diagnostics](../docs-dev/ui/pages/diagnostics.md)
- [State Architecture](../docs-dev/architecture/state.md)
- [Icons](../docs-dev/ui/pages/icons.md) – [icon docs](../docs-dev/ui/icons/overview.md)
- [Manuals](../docs-dev/ui/pages/manuals.md)
- [Navigation](../docs-dev/ui/navigation/overview.md)
50 changes: 39 additions & 11 deletions packages/lib/std/src/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,65 @@
import { Subscription, Terminable } from "./terminable";
import { int, Procedure, safeExecute } from "./lang";

/**
* Collection managing lifecycle of event listeners.
*
* @template T Type describing the listener interface.
*/
export class Listeners<T> implements Terminable {
readonly #set = new Set<T>();
readonly #proxy: Required<T>;

constructor() {
this.#proxy = new Proxy({}, {
get:
(_: never, func: string): (() => void) =>
(...args: unknown[]): void =>
this.#set.forEach((listener: any) => {
if (Object.getPrototypeOf(listener) === Object.getPrototypeOf({})) {
return safeExecute(listener[func], ...args);
}
return listener[func]?.apply(listener, args);
}),
} as const) as Required<T>;
this.#proxy = new Proxy(
{},
{
get:
(_: never, func: string): (() => void) =>
(...args: unknown[]): void =>
this.#set.forEach((listener: any) => {
if (Object.getPrototypeOf(listener) === Object.getPrototypeOf({})) {
return safeExecute(listener[func], ...args);
}
return listener[func]?.apply(listener, args);
}),
} as const,
) as Required<T>;
}

/**
* Proxy forwarding method calls to all registered listeners.
*/
get proxy(): Required<T> {
return this.#proxy;
}

/** Number of registered listeners. */
get size(): int {
return this.#set.size;
}

/**
* Register a listener instance.
*
* @param listener - Listener to add.
* @returns Subscription used to remove the listener.
*/
subscribe(listener: T): Subscription {
this.#set.add(listener);
return { terminate: () => this.#set.delete(listener) };
}

/**
* Iterate through all listeners with a callback.
*
* @param procedure - Callback executed for each listener.
*/
forEach(procedure: Procedure<T>): void {
this.#set.forEach(procedure);
}

/** Remove all registered listeners. */
terminate(): void {
this.#set.clear();
}
Expand Down
12 changes: 12 additions & 0 deletions packages/lib/std/src/observables.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/**
* Observable primitives and helpers.
*/
import {Subscription, Terminable} from "./terminable"
import {Notifier} from "./notifier"
import {Option} from "./option"
Expand All @@ -20,6 +23,12 @@ export interface ObservableValue<T> extends Observable<ObservableValue<T>> {
}

export namespace ObservableValue {
/**
* Creates an immutable observable that always exposes the given value.
*
* @param value - The value to wrap.
* @returns Observable that never changes.
*/
export const make = <T>(value: T): ObservableValue<T> => new class implements ObservableValue<T> {
getValue(): T {return value}
subscribe(_observer: Observer<ObservableValue<T>>): Subscription {return Terminable.Empty}
Expand Down Expand Up @@ -52,6 +61,9 @@ export namespace MutableObservableValue {
/**
* Creates a view on the given boolean observable that presents and
* writes the negated value.
*
* @param observableValue - Source observable boolean.
* @returns Observable reflecting the inverted state.
*/
export const inverseBoolean = (observableValue: MutableObservableValue<boolean>): MutableObservableValue<boolean> =>
new class implements MutableObservableValue<boolean> {
Expand Down
6 changes: 5 additions & 1 deletion packages/lib/std/src/observers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/**
* Observation callback utilities.
* @packageDocumentation
*/
import {Procedure} from "./lang"

/** Callback invoked when an {@link Observable} emits a value. */
export type Observer<VALUE> = Procedure<VALUE>
export type Observer<VALUE> = Procedure<VALUE>