537 lines
13 KiB
Svelte
537 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import TopBar from './lib/components/ui/TopBar.svelte';
|
|
import EditorWithLogs from './lib/components/editor/EditorWithLogs.svelte';
|
|
import EditorSettings from './lib/components/editor/EditorSettings.svelte';
|
|
import FileBrowser from './lib/components/ui/FileBrowser.svelte';
|
|
import SidePanel from './lib/components/ui/SidePanel.svelte';
|
|
import ResizablePopup from './lib/components/ui/ResizablePopup.svelte';
|
|
import AudioScope from './lib/components/audio/AudioScope.svelte';
|
|
import Spectrogram from './lib/components/audio/Spectrogram.svelte';
|
|
import ConfirmDialog from './lib/components/ui/ConfirmDialog.svelte';
|
|
import InputDialog from './lib/components/ui/InputDialog.svelte';
|
|
import { createCsoundDerivedStores, type LogEntry } from './lib/csound';
|
|
import { type CsoundProject } from './lib/project-system';
|
|
import { DEFAULT_CSOUND_TEMPLATE } from './lib/config/templates';
|
|
import { createAppContext, setAppContext } from './lib/contexts/app-context';
|
|
import {
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
PanelRightClose,
|
|
PanelRightOpen,
|
|
PanelBottomClose,
|
|
PanelBottomOpen,
|
|
LayoutGrid,
|
|
Save,
|
|
Share2,
|
|
Activity
|
|
} from 'lucide-svelte';
|
|
|
|
const appContext = createAppContext();
|
|
setAppContext(appContext);
|
|
|
|
const { csound, projectManager, editorSettings, projectEditor, uiState } = appContext;
|
|
const csoundDerived = createCsoundDerivedStores(csound);
|
|
|
|
let analyserNode = $state<AnalyserNode | null>(null);
|
|
let interpreterLogs = $state<LogEntry[]>([]);
|
|
|
|
let logsUnsubscribe: (() => void) | undefined;
|
|
|
|
onMount(async () => {
|
|
await projectManager.init();
|
|
|
|
const result = await projectManager.getAllProjects();
|
|
if (result.success && result.data.length === 0) {
|
|
await projectManager.createProject({
|
|
title: 'Template',
|
|
author: 'System',
|
|
content: DEFAULT_CSOUND_TEMPLATE,
|
|
tags: []
|
|
});
|
|
}
|
|
|
|
logsUnsubscribe = csoundDerived.logs.subscribe(logs => {
|
|
interpreterLogs = logs;
|
|
});
|
|
});
|
|
|
|
onDestroy(async () => {
|
|
logsUnsubscribe?.();
|
|
await csound.destroy();
|
|
});
|
|
|
|
async function handleEnableAudio() {
|
|
try {
|
|
await csound.init();
|
|
uiState.closeAudioPermission();
|
|
} catch (error) {
|
|
console.error('Failed to initialize audio:', error);
|
|
}
|
|
}
|
|
|
|
function handleEditorChange(value: string) {
|
|
projectEditor.setContent(value);
|
|
}
|
|
|
|
function handleNewFile() {
|
|
const result = projectEditor.requestSwitch(
|
|
() => projectEditor.createNew(DEFAULT_CSOUND_TEMPLATE)
|
|
);
|
|
|
|
if (result === 'confirm-unsaved') {
|
|
uiState.showUnsavedChangesDialog();
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(project: CsoundProject | null) {
|
|
if (!project) return;
|
|
|
|
const result = projectEditor.requestSwitch(
|
|
() => projectEditor.loadProject(project)
|
|
);
|
|
|
|
if (result === 'confirm-unsaved') {
|
|
uiState.showUnsavedChangesDialog();
|
|
}
|
|
}
|
|
|
|
async function handleExecute(code: string) {
|
|
try {
|
|
await csound.evaluate(code);
|
|
} catch (error) {
|
|
console.error('Execution error:', error);
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (projectEditor.isNewUnsavedBuffer) {
|
|
uiState.showSaveAsDialog();
|
|
return;
|
|
}
|
|
|
|
await projectEditor.save();
|
|
}
|
|
|
|
async function handleSaveAs(title: string) {
|
|
const success = await projectEditor.saveAs(title);
|
|
if (success) {
|
|
uiState.hideSaveAsDialog();
|
|
}
|
|
}
|
|
|
|
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) {
|
|
await projectEditor.updateMetadata(updates);
|
|
}
|
|
|
|
async function handleSwitchSave() {
|
|
const result = await projectEditor.confirmSaveAndSwitch();
|
|
|
|
if (result === 'show-save-as') {
|
|
uiState.hideUnsavedChangesDialog();
|
|
uiState.showSaveAsDialog();
|
|
} else {
|
|
uiState.hideUnsavedChangesDialog();
|
|
}
|
|
}
|
|
|
|
function handleSwitchDiscard() {
|
|
projectEditor.confirmDiscardAndSwitch();
|
|
uiState.hideUnsavedChangesDialog();
|
|
}
|
|
|
|
async function handleShare() {
|
|
if (!projectEditor.currentProjectId) return;
|
|
|
|
const result = await projectManager.exportProjectToUrl(projectEditor.currentProjectId);
|
|
if (result.success) {
|
|
try {
|
|
await navigator.clipboard.writeText(result.data);
|
|
} catch (err) {
|
|
console.error('Failed to copy to clipboard:', err);
|
|
}
|
|
uiState.showShare(result.data);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (uiState.scopePopupVisible || uiState.spectrogramPopupVisible) {
|
|
analyserNode = csound.getAnalyserNode();
|
|
|
|
const unsubscribe = csound.onAnalyserNodeCreated((node) => {
|
|
analyserNode = node;
|
|
});
|
|
|
|
return unsubscribe;
|
|
} else {
|
|
analyserNode = null;
|
|
}
|
|
});
|
|
|
|
const panelTabs = [
|
|
{
|
|
id: 'editor',
|
|
label: 'Editor',
|
|
content: editorTabContent
|
|
},
|
|
{
|
|
id: 'files',
|
|
label: 'Files',
|
|
content: filesTabContent
|
|
}
|
|
];
|
|
</script>
|
|
|
|
{#snippet editorTabContent()}
|
|
<EditorSettings />
|
|
{/snippet}
|
|
|
|
{#snippet filesTabContent()}
|
|
<FileBrowser
|
|
{projectManager}
|
|
onFileSelect={handleFileSelect}
|
|
onNewFile={handleNewFile}
|
|
onMetadataUpdate={handleMetadataUpdate}
|
|
selectedProjectId={projectEditor.currentProjectId}
|
|
/>
|
|
{/snippet}
|
|
|
|
<div class="app-container">
|
|
<TopBar title="OldBoy">
|
|
{#snippet leftActions()}
|
|
<button
|
|
class="icon-button"
|
|
onclick={handleSave}
|
|
disabled={!projectEditor.hasUnsavedChanges}
|
|
title="Save {projectEditor.hasUnsavedChanges ? '(unsaved changes)' : ''}"
|
|
class:has-changes={projectEditor.hasUnsavedChanges}
|
|
>
|
|
<Save size={18} />
|
|
</button>
|
|
{/snippet}
|
|
<button onclick={() => uiState.toggleSidePanel()} class="icon-button">
|
|
{#if uiState.sidePanelVisible}
|
|
{#if uiState.sidePanelPosition === 'left'}
|
|
<PanelLeftClose size={18} />
|
|
{:else if uiState.sidePanelPosition === 'right'}
|
|
<PanelRightClose size={18} />
|
|
{:else}
|
|
<PanelBottomClose size={18} />
|
|
{/if}
|
|
{:else}
|
|
{#if uiState.sidePanelPosition === 'left'}
|
|
<PanelLeftOpen size={18} />
|
|
{:else if uiState.sidePanelPosition === 'right'}
|
|
<PanelRightOpen size={18} />
|
|
{:else}
|
|
<PanelBottomOpen size={18} />
|
|
{/if}
|
|
{/if}
|
|
</button>
|
|
<button onclick={() => uiState.cyclePanelPosition()} class="icon-button" title="Change panel position">
|
|
<LayoutGrid size={18} />
|
|
</button>
|
|
<button
|
|
onclick={handleShare}
|
|
class="icon-button"
|
|
disabled={!projectEditor.currentProjectId}
|
|
title="Share project"
|
|
>
|
|
<Share2 size={18} />
|
|
</button>
|
|
<button
|
|
onclick={() => uiState.openScope()}
|
|
class="icon-button"
|
|
title="Open audio scope"
|
|
>
|
|
<Activity size={18} />
|
|
</button>
|
|
<button
|
|
onclick={() => uiState.openSpectrogram()}
|
|
class="icon-button"
|
|
title="Open spectrogram"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<path d="M3 9h18"/>
|
|
<path d="M3 15h18"/>
|
|
<path d="M9 3v18"/>
|
|
<path d="M15 3v18"/>
|
|
</svg>
|
|
</button>
|
|
</TopBar>
|
|
|
|
<div class="main-content" class:panel-bottom={uiState.sidePanelPosition === 'bottom'}>
|
|
{#if uiState.sidePanelPosition === 'left'}
|
|
<SidePanel
|
|
bind:visible={uiState.sidePanelVisible}
|
|
bind:position={uiState.sidePanelPosition}
|
|
tabs={panelTabs}
|
|
/>
|
|
{/if}
|
|
|
|
<div class="editor-area">
|
|
<EditorWithLogs
|
|
value={projectEditor.content}
|
|
language="javascript"
|
|
onChange={handleEditorChange}
|
|
onExecute={handleExecute}
|
|
logs={interpreterLogs}
|
|
{editorSettings}
|
|
/>
|
|
</div>
|
|
|
|
{#if uiState.sidePanelPosition === 'right'}
|
|
<SidePanel
|
|
bind:visible={uiState.sidePanelVisible}
|
|
bind:position={uiState.sidePanelPosition}
|
|
tabs={panelTabs}
|
|
/>
|
|
{/if}
|
|
|
|
{#if uiState.sidePanelPosition === 'bottom'}
|
|
<SidePanel
|
|
bind:visible={uiState.sidePanelVisible}
|
|
bind:position={uiState.sidePanelPosition}
|
|
initialWidth={200}
|
|
minWidth={100}
|
|
maxWidth={400}
|
|
tabs={panelTabs}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<ResizablePopup
|
|
bind:visible={uiState.sharePopupVisible}
|
|
title="Share Project"
|
|
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 300 : 300)}
|
|
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 100 : 200)}
|
|
width={600}
|
|
height={200}
|
|
minWidth={400}
|
|
minHeight={150}
|
|
>
|
|
{#snippet children()}
|
|
<div class="share-content">
|
|
<p>Link copied to clipboard!</p>
|
|
<div class="share-url-container">
|
|
<input
|
|
type="text"
|
|
readonly
|
|
value={uiState.shareUrl}
|
|
class="share-url-input"
|
|
onclick={(e) => e.currentTarget.select()}
|
|
/>
|
|
</div>
|
|
<p class="share-instructions">Anyone with this link can import the project.</p>
|
|
</div>
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ResizablePopup
|
|
visible={uiState.audioPermissionPopupVisible}
|
|
title="Audio Permission Required"
|
|
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 250 : 250)}
|
|
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 175 : 200)}
|
|
width={500}
|
|
height={350}
|
|
minWidth={400}
|
|
minHeight={300}
|
|
closable={false}
|
|
>
|
|
{#snippet children()}
|
|
<div class="audio-permission-content">
|
|
<h3>Enable Audio Context</h3>
|
|
<p>OldBoy needs permission to use audio playback.</p>
|
|
<p>Click the button below to enable audio and start using the application.</p>
|
|
<button class="enable-audio-button" onclick={handleEnableAudio}>
|
|
Enable Audio
|
|
</button>
|
|
</div>
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ResizablePopup
|
|
bind:visible={uiState.scopePopupVisible}
|
|
title="Audio Scope"
|
|
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 100)}
|
|
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 100)}
|
|
width={800}
|
|
height={600}
|
|
minWidth={400}
|
|
minHeight={300}
|
|
noPadding={true}
|
|
>
|
|
{#snippet children()}
|
|
<AudioScope analyserNode={analyserNode} />
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ResizablePopup
|
|
bind:visible={uiState.spectrogramPopupVisible}
|
|
title="Spectrogram"
|
|
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 150)}
|
|
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 150)}
|
|
width={800}
|
|
height={600}
|
|
minWidth={400}
|
|
minHeight={300}
|
|
noPadding={true}
|
|
>
|
|
{#snippet children()}
|
|
<Spectrogram analyserNode={analyserNode} />
|
|
{/snippet}
|
|
</ResizablePopup>
|
|
|
|
<ConfirmDialog
|
|
bind:visible={uiState.unsavedChangesDialogVisible}
|
|
title="Unsaved Changes"
|
|
message="You have unsaved changes. What would you like to do?"
|
|
confirmLabel="Save"
|
|
cancelLabel="Discard"
|
|
onConfirm={handleSwitchSave}
|
|
onCancel={handleSwitchDiscard}
|
|
/>
|
|
|
|
<InputDialog
|
|
bind:visible={uiState.saveAsDialogVisible}
|
|
title="Save As"
|
|
label="File name"
|
|
placeholder="Untitled"
|
|
onConfirm={handleSaveAs}
|
|
/>
|
|
</div>
|
|
|
|
<style>
|
|
.app-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.main-content {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.main-content.panel-bottom {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.editor-area {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.icon-button {
|
|
padding: 0.5rem;
|
|
background-color: #333;
|
|
color: rgba(255, 255, 255, 0.87);
|
|
border: 1px solid #555;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.icon-button:hover:not(:disabled) {
|
|
background-color: #444;
|
|
border-color: #646cff;
|
|
}
|
|
|
|
.icon-button:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.icon-button.has-changes {
|
|
color: #646cff;
|
|
}
|
|
|
|
h3 {
|
|
margin-top: 0;
|
|
color: rgba(255, 255, 255, 0.87);
|
|
}
|
|
|
|
p {
|
|
color: rgba(255, 255, 255, 0.7);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.share-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.share-url-container {
|
|
width: 100%;
|
|
}
|
|
|
|
.share-url-input {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
background-color: #2a2a2a;
|
|
border: 1px solid #3a3a3a;
|
|
color: rgba(255, 255, 255, 0.87);
|
|
font-size: 0.875rem;
|
|
font-family: monospace;
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.share-url-input:focus {
|
|
border-color: #646cff;
|
|
}
|
|
|
|
.share-instructions {
|
|
font-size: 0.875rem;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
margin: 0;
|
|
}
|
|
|
|
.audio-permission-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.audio-permission-content h3 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.audio-permission-content p {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.enable-audio-button {
|
|
margin-top: 1rem;
|
|
padding: 0.75rem 2rem;
|
|
background-color: #646cff;
|
|
color: white;
|
|
border: none;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.enable-audio-button:hover {
|
|
background-color: #535bdb;
|
|
}
|
|
|
|
.enable-audio-button:active {
|
|
background-color: #424ab8;
|
|
}
|
|
</style>
|
|
|