573 lines
25 KiB
TypeScript
573 lines
25 KiB
TypeScript
import {
|
|
asInstanceOf,
|
|
DefaultObservableValue,
|
|
EmptyExec,
|
|
Errors,
|
|
Func,
|
|
int,
|
|
isAbsent,
|
|
MutableObservableOption,
|
|
Notifier,
|
|
Nullable,
|
|
Observer,
|
|
Option,
|
|
Provider,
|
|
RuntimeNotifier,
|
|
safeRead,
|
|
Subscription,
|
|
Terminable,
|
|
Terminator,
|
|
tryCatch,
|
|
UUID
|
|
} from "@opendaw/lib-std"
|
|
import {populateStudioMenu} from "@/service/StudioMenu"
|
|
import {Snapping} from "@/ui/timeline/Snapping.ts"
|
|
import {PanelContents} from "@/ui/workspace/PanelContents.tsx"
|
|
import {createPanelFactory} from "@/ui/workspace/PanelFactory.tsx"
|
|
import {SpotlightDataSupplier} from "@/ui/spotlight/SpotlightDataSupplier.ts"
|
|
import {Workspace} from "@/ui/workspace/Workspace.ts"
|
|
import {PanelType} from "@/ui/workspace/PanelType.ts"
|
|
import {Dialogs} from "@/ui/components/dialogs.tsx"
|
|
import {BuildInfo} from "@/BuildInfo.ts"
|
|
import {SamplePlayback} from "@/service/SamplePlayback"
|
|
import {ProjectProfileService} from "./ProjectProfileService"
|
|
import {StudioSignal} from "./StudioSignal"
|
|
import {AudioOutputDevice} from "@/audio/AudioOutputDevice"
|
|
import {FooterLabel} from "@/service/FooterLabel"
|
|
import {RouteLocation} from "@opendaw/lib-jsx"
|
|
import {PPQN} from "@opendaw/lib-dsp"
|
|
import {AnimationFrame, Browser, ConsoleCommands, Dragging, Files} from "@opendaw/lib-dom"
|
|
import {Promises} from "@opendaw/lib-runtime"
|
|
import {ExportStemsConfiguration, InstrumentFactories, PresetDecoder, Sample} from "@opendaw/studio-adapters"
|
|
import {Address} from "@opendaw/lib-box"
|
|
import {
|
|
AudioContentFactory,
|
|
AudioWorklets,
|
|
CloudAuthManager,
|
|
DawProjectService,
|
|
EngineFacade,
|
|
EngineWorklet,
|
|
ExternalLib,
|
|
FilePickerAcceptTypes,
|
|
GlobalSampleLoaderManager,
|
|
GlobalSoundfontLoaderManager,
|
|
Project,
|
|
ProjectEnv,
|
|
ProjectMeta,
|
|
ProjectProfile,
|
|
ProjectStorage,
|
|
Recovery,
|
|
RestartWorklet,
|
|
SampleService,
|
|
SoundfontService,
|
|
StudioPreferences,
|
|
TimelineRange
|
|
} from "@opendaw/studio-core"
|
|
import {ProjectDialogs} from "@/project/ProjectDialogs"
|
|
import {AudioFileBox, AudioUnitBox} from "@opendaw/studio-boxes"
|
|
import {AudioUnitType} from "@opendaw/studio-enums"
|
|
import {Surface} from "@/ui/surface/Surface"
|
|
import {SoftwareMIDIPanel} from "@/ui/software-midi/SoftwareMIDIPanel"
|
|
import {Mixdowns} from "@/service/Mixdowns"
|
|
import {ShadertoyState} from "@/ui/shadertoy/ShadertoyState"
|
|
import {CodeEditorState} from "@/ui/werkstatt-editor/CodeEditorState"
|
|
import {AceStepGenerationService} from "@/service/AceStepGenerationService"
|
|
|
|
/**
|
|
* I am just piling stuff after stuff in here to boot the environment.
|
|
* I suppose this gets cleaned up sooner or later.
|
|
*/
|
|
|
|
const range = new TimelineRange({padding: 12})
|
|
range.minimum = PPQN.fromSignature(3, 8)
|
|
range.maxUnits = PPQN.fromSignature(128, 1)
|
|
range.showUnitInterval(0, PPQN.fromSignature(9, 1))
|
|
|
|
const snapping = new Snapping(range)
|
|
|
|
export class StudioService implements ProjectEnv {
|
|
readonly layout = {
|
|
screen: new DefaultObservableValue<Nullable<Workspace.ScreenKeys>>("default")
|
|
} as const
|
|
readonly timeline = {
|
|
range,
|
|
snapping,
|
|
clips: {
|
|
count: new DefaultObservableValue(3),
|
|
visible: new DefaultObservableValue(true)
|
|
},
|
|
followCursor: new DefaultObservableValue(false),
|
|
primaryVisibility: {
|
|
markers: new DefaultObservableValue(true),
|
|
tempo: new DefaultObservableValue(false),
|
|
signature: new DefaultObservableValue(false)
|
|
}
|
|
} as const
|
|
readonly menu = populateStudioMenu(this)
|
|
readonly panelLayout = new PanelContents(createPanelFactory(this))
|
|
readonly spotlightDataSupplier = new SpotlightDataSupplier()
|
|
readonly samplePlayback: SamplePlayback
|
|
readonly aceStepGeneration = new AceStepGenerationService()
|
|
readonly recovery = new Recovery(() => this.#projectProfileService.getValue(), this)
|
|
readonly engine = new EngineFacade()
|
|
|
|
readonly #softwareKeyboardLifeCycle = new Terminator()
|
|
readonly #signals = new Notifier<StudioSignal>()
|
|
readonly #projectProfileService: ProjectProfileService
|
|
readonly #sampleService: SampleService
|
|
readonly #soundfontService: SoundfontService
|
|
|
|
#shadertoyState: Option<ShadertoyState> = Option.None
|
|
readonly #activeCodeEditor: MutableObservableOption<CodeEditorState> = new MutableObservableOption()
|
|
|
|
#factoryFooterLabel: Option<Provider<FooterLabel>> = Option.None
|
|
|
|
regionModifierInProgress: boolean = false
|
|
|
|
constructor(readonly audioContext: AudioContext,
|
|
readonly audioWorklets: AudioWorklets,
|
|
readonly audioDevices: AudioOutputDevice,
|
|
readonly sampleManager: GlobalSampleLoaderManager,
|
|
readonly soundfontManager: GlobalSoundfontLoaderManager,
|
|
readonly cloudAuthManager: CloudAuthManager,
|
|
readonly buildInfo: BuildInfo) {
|
|
this.#sampleService = new SampleService(audioContext)
|
|
this.#sampleService.subscribe(([sample]) => this.#signals.notify({
|
|
type: "import-sample",
|
|
sample
|
|
}))
|
|
this.#soundfontService = new SoundfontService()
|
|
this.#soundfontService.subscribe(([soundfont]) => this.#signals.notify({
|
|
type: "import-soundfont",
|
|
soundfont
|
|
}))
|
|
this.samplePlayback = new SamplePlayback()
|
|
this.#projectProfileService = new ProjectProfileService({
|
|
env: this,
|
|
sampleService: this.#sampleService, sampleManager: this.sampleManager,
|
|
soundfontService: this.#soundfontService, soundfontManager: this.soundfontManager
|
|
})
|
|
|
|
this.#listenProject()
|
|
this.#installConsoleCommands()
|
|
this.#populateSpotlightData()
|
|
this.#configBeforeUnload()
|
|
this.#checkRecovery()
|
|
this.#listenPreferences()
|
|
}
|
|
|
|
get sampleRate(): number {return this.audioContext.sampleRate}
|
|
get sampleService(): SampleService {return this.#sampleService}
|
|
get soundfontService(): SoundfontService {return this.#soundfontService}
|
|
get projectProfileService(): ProjectProfileService {return this.#projectProfileService}
|
|
|
|
panicEngine(): void {this.runIfProject(({engine}) => engine.panic())}
|
|
|
|
async newProject() {
|
|
if (this.hasProfile && !this.project.editing.hasNoChanges()) {
|
|
const approved = await RuntimeNotifier.approve({
|
|
headline: "Closing Project?",
|
|
message: "You will lose all progress!"
|
|
})
|
|
if (!approved) {return}
|
|
}
|
|
this.#projectProfileService.setValue(Option.wrap(
|
|
new ProjectProfile(UUID.generate(), Project.new(this), ProjectMeta.init("Untitled"), Option.None)))
|
|
}
|
|
|
|
async closeProject() {
|
|
RouteLocation.get().navigateTo("/")
|
|
if (!this.hasProfile) {
|
|
this.switchScreen("dashboard")
|
|
return
|
|
}
|
|
if (this.project.editing.hasNoChanges()) {
|
|
this.#projectProfileService.setValue(Option.None)
|
|
} else {
|
|
const approved = await RuntimeNotifier.approve({
|
|
headline: "Closing Project?",
|
|
message: "You will lose all progress!"
|
|
})
|
|
if (approved) {this.#projectProfileService.setValue(Option.None)}
|
|
}
|
|
}
|
|
|
|
async browseLocalProjects(): Promise<void> {
|
|
const {status, value} = await Promises.tryCatch(ProjectDialogs.showBrowseDialog(this))
|
|
if (status === "resolved") {
|
|
const [uuid, meta] = value
|
|
await this.#projectProfileService.load(uuid, meta)
|
|
}
|
|
}
|
|
|
|
async exportBundle() {return this.#projectProfileService.exportBundle()}
|
|
async importBundle() {return this.#projectProfileService.importBundle()}
|
|
async deleteProject(uuid: UUID.Bytes, meta: ProjectMeta): Promise<void> {
|
|
if (this.#projectProfileService.getValue().ifSome(profile => UUID.equals(profile.uuid, uuid)) === true) {
|
|
await this.closeProject()
|
|
}
|
|
const {status} = await Promises.tryCatch(ProjectStorage.deleteProject(uuid))
|
|
if (status === "resolved") {
|
|
this.#signals.notify({type: "delete-project", meta})
|
|
}
|
|
}
|
|
|
|
async exportMixdown() {
|
|
return this.#projectProfileService.getValue()
|
|
.ifSome(async (profile) => {
|
|
await this.audioContext.suspend()
|
|
const {status, error} = await Promises.tryCatch(Mixdowns.exportMixdown(profile))
|
|
if (status === "rejected" && !Errors.isAbort(error)) {
|
|
await RuntimeNotifier.info({headline: "Export Failed", message: String(error)})
|
|
}
|
|
this.audioContext.resume().then()
|
|
})
|
|
}
|
|
|
|
async importGeneratedSample(name: string, audio: Blob): Promise<Sample> {
|
|
const arrayBuffer = await audio.arrayBuffer()
|
|
return this.#sampleService.importFile({name, arrayBuffer})
|
|
}
|
|
|
|
async exportStems() {
|
|
return this.#projectProfileService.getValue()
|
|
.ifSome(async (profile) => {
|
|
const {project} = profile
|
|
if (project.rootBox.audioUnits.pointerHub.incoming()
|
|
.every(({box}) => asInstanceOf(box, AudioUnitBox).type.getValue() === AudioUnitType.Output)) {
|
|
return RuntimeNotifier.info({
|
|
headline: "Export Error",
|
|
message: "No stems to export"
|
|
})
|
|
}
|
|
const {status: dialogStatus, error: dialogError, value: config} =
|
|
await Promises.tryCatch(ProjectDialogs.showExportStemsDialog(project))
|
|
if (dialogStatus === "rejected") {
|
|
if (Errors.isAbort(dialogError)) {return}
|
|
await RuntimeNotifier.info({headline: "Export Failed", message: String(dialogError)})
|
|
return
|
|
}
|
|
ExportStemsConfiguration.sanitizeExportNamesInPlace(config)
|
|
await this.audioContext.suspend()
|
|
const {status, error} = await Promises.tryCatch(Mixdowns.exportStems(profile, config))
|
|
if (status === "rejected" && !Errors.isAbort(error)) {
|
|
await RuntimeNotifier.info({headline: "Export Failed", message: String(error)})
|
|
}
|
|
this.audioContext.resume().then(EmptyExec, EmptyExec)
|
|
})
|
|
}
|
|
|
|
async importDawproject() {
|
|
(await DawProjectService.importDawproject(this.sampleService))
|
|
.ifSome(skeleton => this.#projectProfileService
|
|
.setProject(Project.fromSkeleton(this, skeleton), "Dawproject"))
|
|
}
|
|
|
|
async exportDawproject() {
|
|
return this.#projectProfileService.getValue().ifSome(profile => DawProjectService.exportDawproject(profile))
|
|
}
|
|
|
|
async importPreset() {
|
|
const {
|
|
status,
|
|
value: files
|
|
} = await Promises.tryCatch(Files.open({types: [FilePickerAcceptTypes.PresetFileType]}))
|
|
if (status === "rejected") {return}
|
|
if (files.length === 0) {return}
|
|
const bytes = await files[0].arrayBuffer()
|
|
console.debug("importing preset", bytes.byteLength)
|
|
if (this.hasProfile) {
|
|
const {editing, skeleton} = this.project
|
|
editing.modify(() => PresetDecoder.decode(bytes, skeleton))
|
|
} else {
|
|
const project = Project.new(this)
|
|
const {editing, skeleton} = project
|
|
editing.modify(() => PresetDecoder.decode(bytes, skeleton))
|
|
this.#projectProfileService.setValue(Option.wrap(
|
|
new ProjectProfile(UUID.generate(), project, ProjectMeta.init("Untitled"), Option.None)))
|
|
}
|
|
}
|
|
|
|
async importStems(): Promise<void> {
|
|
const fileResult = await Promises.tryCatch(Files.open({types: [FilePickerAcceptTypes.ZipFileType]}))
|
|
if (fileResult.status === "rejected") {return}
|
|
const firstFile = fileResult.value.at(0)
|
|
if (isAbsent(firstFile)) {return}
|
|
const {status, value: JSZip} = await ExternalLib.JSZip()
|
|
if (status === "rejected") {return}
|
|
const zipResult = await Promises.tryCatch(JSZip.loadAsync(await firstFile.arrayBuffer()))
|
|
if (zipResult.status === "rejected") {
|
|
await RuntimeNotifier.info({headline: "Import Failed", message: String(zipResult.error)})
|
|
return
|
|
}
|
|
const audioEntries = Object.entries(zipResult.value.files)
|
|
.filter(([path, file]) => {
|
|
if (file.dir) {return false}
|
|
const lower = path.toLowerCase()
|
|
if (!lower.endsWith(".wav")) {return false}
|
|
if (lower.startsWith("__macosx/")) {return false}
|
|
const name = path.substring(path.lastIndexOf("/") + 1)
|
|
return !name.startsWith("._")
|
|
})
|
|
if (audioEntries.length === 0) {return}
|
|
if (!this.hasProfile) {
|
|
this.#projectProfileService.setValue(Option.wrap(
|
|
new ProjectProfile(UUID.generate(), Project.new(this), ProjectMeta.init("Untitled"), Option.None)))
|
|
}
|
|
const {editing, boxGraph} = this.project
|
|
let dialog = RuntimeNotifier.progress({headline: "Importing Stems..."})
|
|
for (let index = 0; index < audioEntries.length; index++) {
|
|
const [path, file] = audioEntries[index]
|
|
const name = path.substring(path.lastIndexOf("/") + 1).replace(/\.wav$/i, "")
|
|
dialog.message = `Importing ${name} (${index + 1}/${audioEntries.length})`
|
|
const arrayBuffer = await file.async("arraybuffer").then(buffer => buffer.slice(0))
|
|
const {status, value: sample, error} = await Promises.tryCatch(this.#sampleService.importFile({
|
|
name,
|
|
arrayBuffer
|
|
}))
|
|
if (status === "rejected") {
|
|
console.warn(`Failed to import '${name}'`, error)
|
|
dialog.terminate()
|
|
const skip = await RuntimeNotifier.approve({
|
|
headline: `Failed to import '${name}'`,
|
|
message: String(error),
|
|
approveText: "Skip",
|
|
cancelText: "Cancel Import"
|
|
})
|
|
if (!skip) {break}
|
|
dialog = RuntimeNotifier.progress({headline: "Importing Stems..."})
|
|
continue
|
|
}
|
|
const uuid = UUID.parse(sample.uuid)
|
|
await Promises.tryCatch(this.sampleManager.getAudioData(uuid))
|
|
editing.modify(() => {
|
|
const {trackBox, instrumentBox} = this.project.api.createInstrument(InstrumentFactories.Tape)
|
|
instrumentBox.label.setValue(name)
|
|
const audioFileBox = boxGraph.findBox<AudioFileBox>(uuid)
|
|
.unwrapOrElse(() => AudioFileBox.create(boxGraph, uuid, box => {
|
|
box.fileName.setValue(name)
|
|
box.startInSeconds.setValue(0)
|
|
box.endInSeconds.setValue(sample.duration)
|
|
}))
|
|
AudioContentFactory.createNotStretchedRegion({
|
|
boxGraph, sample, audioFileBox, position: 0, targetTrack: trackBox
|
|
})
|
|
})
|
|
}
|
|
dialog.terminate()
|
|
}
|
|
|
|
runIfProject<R>(procedure: Func<Project, R>): Option<R> {
|
|
return this.#projectProfileService.getValue().map(({project}) => procedure(project))
|
|
}
|
|
|
|
get project(): Project {return this.profile.project}
|
|
get optProject(): Option<Project> {return this.projectProfileService.getValue().map(({project}) => project)}
|
|
get profile(): ProjectProfile {return this.#projectProfileService.getValue().unwrap("No profile available")}
|
|
get hasProfile(): boolean {return this.#projectProfileService.getValue().nonEmpty()}
|
|
|
|
subscribeSignal<T extends StudioSignal["type"]>(
|
|
observer: Observer<Extract<StudioSignal, { type: T }>>, type: T): Subscription {
|
|
return this.#signals.subscribe(signal => {
|
|
if (signal.type === type) {
|
|
observer(signal as Extract<StudioSignal, { type: T }>)
|
|
}
|
|
})
|
|
}
|
|
|
|
switchScreen(key: Nullable<Workspace.ScreenKeys>): void {
|
|
this.layout.screen.setValue(key)
|
|
RouteLocation.get().navigateTo("/")
|
|
}
|
|
|
|
registerFooter(factory: Provider<FooterLabel>): void {
|
|
this.#factoryFooterLabel = Option.wrap(factory)
|
|
}
|
|
|
|
factoryFooterLabel(): Option<Provider<FooterLabel>> {return this.#factoryFooterLabel}
|
|
|
|
get optShadertoyState(): Option<ShadertoyState> {return this.#shadertoyState}
|
|
get activeCodeEditor(): MutableObservableOption<CodeEditorState> {return this.#activeCodeEditor}
|
|
|
|
openCodeEditor(state: CodeEditorState): void {
|
|
const previousScreen = this.layout.screen.getValue() === "code"
|
|
? this.#activeCodeEditor.map(existing => existing.previousScreen).unwrapOrNull() ?? state.previousScreen
|
|
: state.previousScreen
|
|
this.layout.screen.setValue(null)
|
|
this.#activeCodeEditor.wrap({...state, previousScreen})
|
|
this.layout.screen.setValue("code")
|
|
RouteLocation.get().navigateTo("/")
|
|
}
|
|
|
|
closeCodeEditor(): void {
|
|
const previousScreen = this.#activeCodeEditor.map(state => state.previousScreen).unwrapOrNull()
|
|
this.#activeCodeEditor.clear()
|
|
if (this.layout.screen.getValue() === "code") {
|
|
// Defer the screen switch to avoid cascading UI updates during synchronous
|
|
// box deletion. Switching the screen triggers DevicePanel re-evaluation which
|
|
// clears mounts before remaining pointerHub onRemoved events have finished.
|
|
queueMicrotask(() => this.layout.screen.setValue(previousScreen ?? "default"))
|
|
}
|
|
}
|
|
|
|
resetPeaks(): void {this.#signals.notify({type: "reset-peaks"})}
|
|
|
|
async verifyProject() {
|
|
if (!this.hasProfile) {return}
|
|
const {boxGraph} = this.project
|
|
const result = boxGraph.verifyPointers()
|
|
await RuntimeNotifier.info({message: `Project is okay. All ${result.count} pointers are fine.`})
|
|
}
|
|
|
|
toggleSoftwareKeyboard(): void {
|
|
if (this.isSoftwareKeyboardVisible()) {
|
|
this.#softwareKeyboardLifeCycle.terminate()
|
|
} else {
|
|
const element = SoftwareMIDIPanel({
|
|
lifecycle: this.#softwareKeyboardLifeCycle,
|
|
service: this
|
|
})
|
|
Surface.get(window).floating.appendChild(element)
|
|
this.#softwareKeyboardLifeCycle.own(Terminable.create(() => element.remove()))
|
|
}
|
|
}
|
|
|
|
isSoftwareKeyboardVisible(): boolean {return this.#softwareKeyboardLifeCycle.nonEmpty()}
|
|
|
|
#listenProject(): void {
|
|
const lifeTime = new Terminator()
|
|
const observer = (optProfile: Option<ProjectProfile>) => {
|
|
const path = RouteLocation.get().path
|
|
const isRoot = path === "/"
|
|
if (isRoot) {this.layout.screen.setValue(null)}
|
|
lifeTime.terminate()
|
|
document.body.classList.toggle("no-project", optProfile.isEmpty())
|
|
if (optProfile.nonEmpty()) {
|
|
const profile = optProfile.unwrap()
|
|
const {project, meta} = profile
|
|
console.debug(`switch to %c${meta.name}%c`, "color: hsl(25, 69%, 63%)", "color: inherit")
|
|
const {timelineBox, timelineBoxAdapter, userEditingManager} = project
|
|
range.showUnitInterval(0, PPQN.fromSignature(9, 1))
|
|
this.#shadertoyState = Option.wrap(lifeTime.own(new ShadertoyState(project)))
|
|
//
|
|
// -------------------------------
|
|
// Show views if content available
|
|
// -------------------------------
|
|
//
|
|
// Markers
|
|
this.timeline.primaryVisibility.markers.setValue(true)
|
|
// Tempo
|
|
this.timeline.primaryVisibility.tempo.setValue(timelineBoxAdapter
|
|
.tempoTrackEvents.mapOr(collection => !collection.events.isEmpty(), false))
|
|
// Signature
|
|
this.timeline.primaryVisibility.signature.setValue(timelineBoxAdapter.signatureTrack.size > 0)
|
|
// Clips
|
|
const maxClipIndex: int = project.rootBoxAdapter.audioUnits.adapters()
|
|
.reduce((max, unit) => Math.max(max, unit.tracks.values()
|
|
.reduce((max, track) => Math.max(max, track.clips.collection
|
|
.getMinFreeIndex()), 0)), 0)
|
|
if (maxClipIndex > 0 || StudioPreferences.settings.visibility["auto-open-clips"]) {
|
|
this.timeline.clips.count.setValue(Math.max(maxClipIndex + 1, 3))
|
|
this.timeline.clips.visible.setValue(true)
|
|
} else {
|
|
this.timeline.clips.count.setValue(3)
|
|
this.timeline.clips.visible.setValue(false)
|
|
}
|
|
let screen: Nullable<Workspace.ScreenKeys> = null
|
|
const restart: RestartWorklet = {
|
|
unload: async (event: unknown) => {
|
|
screen = this.layout.screen.getValue()
|
|
// we need to restart the screen to subscribe to new broadcaster instances
|
|
this.switchScreen(null)
|
|
this.engine.releaseWorklet()
|
|
return Dialogs.info({
|
|
headline: "Audio-Engine Error",
|
|
message: String(safeRead(event, "error", "message") ?? "Unknown error"),
|
|
okText: "Restart Engine",
|
|
cancelable: false
|
|
})
|
|
},
|
|
load: (engine: EngineWorklet) => {
|
|
this.engine.setWorklet(engine)
|
|
this.switchScreen(screen)
|
|
}
|
|
}
|
|
this.engine.releaseWorklet()
|
|
const {status, value: worklet, error} = tryCatch(() => project.startAudioWorklet(restart, {}))
|
|
if (status === "failure") {
|
|
Dialogs.info({
|
|
headline: "Audio-Engine Error",
|
|
message: `Could not start the audio engine. Your browser may not support all required features. (${Errors.toString(error)})`,
|
|
okText: "OK",
|
|
cancelable: false
|
|
}).finally()
|
|
return
|
|
}
|
|
this.engine.setWorklet(worklet)
|
|
lifeTime.ownAll(
|
|
project,
|
|
snapping.registerSignatureTrackAdapter(project.timelineBoxAdapter.signatureTrack),
|
|
userEditingManager.timeline.catchupAndSubscribe(option => option
|
|
.ifSome(() => AnimationFrame.once(() => this.panelLayout.showIfAvailable(PanelType.ContentEditor)))),
|
|
timelineBox.durationInPulses.catchupAndSubscribe(owner => range.maxUnits = owner.getValue() + PPQN.Bar)
|
|
)
|
|
if (isRoot) {this.switchScreen("default")}
|
|
} else {
|
|
this.engine.releaseWorklet()
|
|
range.maxUnits = PPQN.fromSignature(128, 1)
|
|
range.showUnitInterval(0, PPQN.fromSignature(9, 1))
|
|
this.layout.screen.setValue("dashboard")
|
|
}
|
|
}
|
|
this.#projectProfileService.catchupAndSubscribe(observer)
|
|
}
|
|
|
|
#installConsoleCommands(): void {
|
|
ConsoleCommands.exportAccessor("box.graph.boxes",
|
|
() => this.runIfProject(({boxGraph}) => boxGraph.debugBoxes()))
|
|
ConsoleCommands.exportMethod("box.graph.lookup",
|
|
(address: string) => this.runIfProject(({boxGraph}) => boxGraph.findVertex(Address.decode(address)).match({
|
|
none: () => "not found",
|
|
some: vertex => vertex.toString()
|
|
})).match({none: () => "no project", some: value => value}))
|
|
ConsoleCommands.exportAccessor("box.graph.dependencies",
|
|
() => this.runIfProject(project => project.boxGraph.debugDependencies()))
|
|
}
|
|
|
|
#populateSpotlightData(): void {
|
|
this.spotlightDataSupplier.registerAction("Create Synth", EmptyExec)
|
|
this.spotlightDataSupplier.registerAction("Create Drumcomputer", EmptyExec)
|
|
this.spotlightDataSupplier.registerAction("Create ModularSystem", EmptyExec)
|
|
}
|
|
|
|
#configBeforeUnload(): void {
|
|
if (!Browser.isLocalHost()) {
|
|
window.addEventListener("beforeunload", (event: Event) => {
|
|
if (!navigator.onLine) {event.preventDefault()}
|
|
if (this.hasProfile && this.profile.hasUnsavedChanges()) {
|
|
event.preventDefault()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#checkRecovery(): void {
|
|
this.recovery.restoreProfile().then(optProfile => {
|
|
if (optProfile.nonEmpty()) {
|
|
this.#projectProfileService.setValue(optProfile)
|
|
}
|
|
}, EmptyExec)
|
|
}
|
|
|
|
#listenPreferences(): void {
|
|
StudioPreferences.catchupAndSubscribe(value =>
|
|
Dragging.usePointerLock = value && Browser.isChrome(), "pointer", "dragging-use-pointer-lock")
|
|
StudioPreferences.catchupAndSubscribe(value =>
|
|
document.body.classList.toggle("experimental-visible", value), "debug", "enable-beta-features")
|
|
StudioPreferences.catchupAndSubscribe(value =>
|
|
document.body.classList.toggle("help-hidden", !value), "visibility", "visible-help-hints")
|
|
StudioPreferences.catchupAndSubscribe(value =>
|
|
document.body.classList.toggle("scrollbar-padding", value), "visibility", "scrollbar-padding")
|
|
}
|
|
}
|