diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardComponent.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardComponent.kt index a0f6c50..e359f82 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardComponent.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardComponent.kt @@ -38,6 +38,7 @@ class KeyboardComponent( initialOctave: Int = 4, val keyboardWidth: Int = 210, val keyboardHeight: Int = keyboardWidth / 2, + val rounding: Int = 4, val onNoteDown: (Int) -> Unit = {}, val onNoteUp: (Int) -> Unit = {} ) : Komponent() { @@ -222,7 +223,7 @@ class KeyboardComponent( 0, whiteKeyWidth, keyboardHeight, - 0, + rounding, if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name ) } @@ -238,7 +239,7 @@ class KeyboardComponent( 0, blackKeyWidth, blackKeyHeight, - 0, + rounding, if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name ) } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardInputComponent.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardInputComponent.kt deleted file mode 100644 index f6d44d6..0000000 --- a/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardInputComponent.kt +++ /dev/null @@ -1,29 +0,0 @@ -package nl.astraeus.vst.ui.components - -import kotlinx.html.div -import nl.astraeus.komp.HtmlBuilder -import nl.astraeus.komp.Komponent -import nl.astraeus.vst.ui.css.Css.defineCss -import nl.astraeus.vst.ui.css.CssId -import nl.astraeus.vst.ui.css.CssName - -class KeyboardInputComponent : Komponent() { - override fun HtmlBuilder.render() { - div { - +"Keyboard component" - } - } - - companion object : CssId("keyboard-input") { - object KeyboardInputCss : CssName() - - init { - defineCss { - select(KeyboardInputCss.cls()) { - - } - } - } - - } -} \ No newline at end of file diff --git a/src/jsMain/kotlin/nl/astraeus/vst/ui/util/SVGFunctions.kt b/src/jsMain/kotlin/nl/astraeus/vst/ui/util/SVGFunctions.kt index 6e6a4c8..a9ab4f4 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/ui/util/SVGFunctions.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/ui/util/SVGFunctions.kt @@ -45,12 +45,12 @@ fun SVG.rect( y: Int, width: Int, height: Int, - rx: Int, - cls: String + rounding: Int, + cls: String, ) { this.unsafe { + """ - + """.trimIndent() } } diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/base/web/WebsocketHandler.kt b/src/jvmMain/kotlin/nl/astraeus/vst/base/web/WebsocketHandler.kt index 98af233..e3b078c 100644 --- a/src/jvmMain/kotlin/nl/astraeus/vst/base/web/WebsocketHandler.kt +++ b/src/jvmMain/kotlin/nl/astraeus/vst/base/web/WebsocketHandler.kt @@ -13,6 +13,7 @@ import nl.astraeus.vst.base.db.Database import nl.astraeus.vst.base.db.PatchDao import nl.astraeus.vst.base.db.PatchEntity import java.io.File +import java.io.FileOutputStream import java.nio.ByteBuffer import java.security.MessageDigest @@ -22,7 +23,7 @@ class WebsocketHandler( companion object { // Ensure the data directory exists - private val dataDir = File(Settings.dataDir).apply { + private val filesDir = File(Settings.dataDir, "files").apply { if (!exists()) { mkdirs() } @@ -31,7 +32,7 @@ class WebsocketHandler( fun fileExists(hash: String): Boolean { check(hash.length == 64) { "Hash must be 64 characters long" } - var currentDir = dataDir + var currentDir = filesDir var remaining = hash while(remaining.length > 8) { val subDir = remaining.substring(0, 8) @@ -49,7 +50,7 @@ class WebsocketHandler( fun getFileFromHash(hash: String): File { check(hash.length == 64) { "Hash must be 64 characters long" } - var currentDir = dataDir + var currentDir = filesDir var remaining = hash while(remaining.length > 8) { val subDir = remaining.substring(0, 8) @@ -98,7 +99,11 @@ class WebsocketHandler( PatchDao.insert(PatchEntity(0, patchId, value)) } } - WebSockets.sendText("SAVED\n$patchId", channel, null) + WebSockets.sendText( + "SAVED\n$patchId", + channel, + null + ) } } @@ -109,7 +114,11 @@ class WebsocketHandler( val patchEntity = PatchDao.findById(patchId) if (patchEntity != null) { - WebSockets.sendText("LOAD\n${patchEntity.patch}", channel, null) + WebSockets.sendText( + "LOAD\n${patchEntity.patch}", + channel, + null + ) } } } @@ -122,7 +131,11 @@ class WebsocketHandler( val file = getFileFromHash(hash) if (file.exists() && file.isFile) { val bytes = file.readBytes() - WebSockets.sendBinary(ByteBuffer.wrap(bytes), channel, null) + WebSockets.sendBinary( + ByteBuffer.wrap(bytes), + channel, + null + ) } } } @@ -131,31 +144,51 @@ class WebsocketHandler( } } - override fun onFullBinaryMessage(channel: WebSocketChannel?, message: BufferedBinaryMessage?) { + override fun onFullBinaryMessage( + channel: WebSocketChannel?, + message: BufferedBinaryMessage? + ) { // Process binary message: create hash from binary, save file in data/files/ directory, // sub directories are 5 characters of the hash per directory if (channel != null && message != null) { try { // Get the binary data val pooled = message.data - val buffer = pooled.resource[0] // Get the first ByteBuffer + val resources = pooled.resource - // Convert ByteBuffer to ByteArray - val bytes = ByteArray(buffer.remaining()) - buffer.get(bytes) + // Collect all bytes from all buffers + var totalSize = 0 - // Free the pooled resource - pooled.free() + // First pass: collect all bytes and calculate total size + for (buffer in resources) { + totalSize += buffer.remaining() + } - // Create hash from binary data - val hash = createHashFromBytes(bytes) + // Combine all bytes into a single array + val combinedBytes = ByteArray(totalSize) + var position = 0 + for (buffer in resources) { + val remaining = buffer.remaining() + buffer.get(combinedBytes, position, remaining) + position += remaining + } + + // Create hash from combined binary data + val hash = createHashFromBytes(combinedBytes) // Save file in data/files/ directory with subdirectories val file = getFileFromHash(hash) - file.writeBytes(bytes) + + // Use FileOutputStream to write all bytes at once + FileOutputStream(file).use { outputStream -> + outputStream.write(combinedBytes) + } // Send the hash back to the client WebSockets.sendText("BINARY_SAVED\n$hash", channel, null) + + // Free the pooled resource after processing + pooled.free() } catch (e: Exception) { WebSockets.sendText("ERROR\n${e.message}", channel, null) } diff --git a/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/Test.kt b/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/Test.kt index 857c5e7..fb9778c 100644 --- a/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/Test.kt +++ b/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/Test.kt @@ -5,6 +5,7 @@ import nl.astraeus.komp.Komponent import nl.astraeus.komp.UnsafeMode import nl.astraeus.vst.ui.css.CssSettings import nl.astraeus.vst.ui.view.MainView +import nl.astraeus.vst.ui.ws.WebsocketClient val mainView: MainView by lazy { MainView() @@ -16,4 +17,8 @@ fun main() { Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY Komponent.create(document.body!!, mainView) + + WebsocketClient.connect { + println("Connected") + } } diff --git a/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/view/MainView.kt b/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/view/MainView.kt index f91dde1..dea78b9 100644 --- a/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/view/MainView.kt +++ b/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/view/MainView.kt @@ -2,11 +2,15 @@ package nl.astraeus.vst.ui.view +import kotlinx.html.InputType import kotlinx.html.div import kotlinx.html.h1 import kotlinx.html.hr +import kotlinx.html.input +import kotlinx.html.js.onChangeFunction import kotlinx.html.js.onClickFunction import kotlinx.html.option +import kotlinx.html.org.w3c.dom.events.Event import kotlinx.html.select import kotlinx.html.span import kotlinx.html.style @@ -37,6 +41,11 @@ import nl.astraeus.vst.ui.css.Css.defineCss import nl.astraeus.vst.ui.css.Css.noTextSelect import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.css.hover +import nl.astraeus.vst.ui.ws.WebsocketClient +import org.w3c.dom.HTMLInputElement +import org.w3c.files.File +import org.w3c.files.FileList +import org.w3c.files.get class MainView : Komponent() { private var messages: MutableList = ArrayList() @@ -113,6 +122,16 @@ class MainView : Komponent() { } */ } + span { + +"Upload file: " + input { + type = InputType.file + onChangeFunction = { + fileInputSelectHandler(it) + requestUpdate() + } + } + } } } div { @@ -134,209 +153,226 @@ class MainView : Komponent() { } hr {} div { - include(KeyboardComponent( - keyboardWidth = keyboardWidth, - keyboardHeight = keyboardWidth / 2, - onNoteDown = { println("Note down: $it") }, - onNoteUp = { println("Note up: $it") }, - )) + include( + KeyboardComponent( + keyboardWidth = keyboardWidth, + keyboardHeight = keyboardWidth / 2, + onNoteDown = { println("Note down: $it") }, + onNoteUp = { println("Note up: $it") }, + ) + ) } - /* - div { - span(ButtonBarCss.name) { - +"SAVE" - onClickFunction = { - val patch = VstChipWorklet.save().copy( - midiId = Midi.currentInput?.id ?: "", - midiName = Midi.currentInput?.name ?: "" - ) + /* + div { + span(ButtonBarCss.name) { + +"SAVE" + onClickFunction = { + val patch = VstChipWorklet.save().copy( + midiId = Midi.currentInput?.id ?: "", + midiName = Midi.currentInput?.name ?: "" + ) - WebsocketClient.send("SAVE\n${JSON.stringify(patch)}") - } - } - span(ButtonBarCss.name) { - +"STOP" - onClickFunction = { - VstChipWorklet.postDirectlyToWorklet( - TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0) - .data.buffer.data - ) - } + WebsocketClient.send("SAVE\n${JSON.stringify(patch)}") } } + span(ButtonBarCss.name) { + +"STOP" + onClickFunction = { + VstChipWorklet.postDirectlyToWorklet( + TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0) + .data.buffer.data + ) + } + } + } */ - div(ControlsCss.name) { - include( - ExpKnobComponent( - value = 0.001, - label = "Volume", - minValue = 0.001, - maxValue = 1.0, - step = 10.0 / 127.0, - width = 100, - height = 120, - ) { value -> + div(ControlsCss.name) { + include( + ExpKnobComponent( + value = 0.001, + label = "Volume", + minValue = 0.001, + maxValue = 1.0, + step = 10.0 / 127.0, + width = 100, + height = 120, + ) { value -> - } - ) - include( - KnobComponent( - value = 0.5, - label = "Duty cycle", - minValue = 0.0, - maxValue = 1.0, - step = 2.0 / 127.0, - width = 100, - height = 120, - ) { value -> + } + ) + include( + KnobComponent( + value = 0.5, + label = "Duty cycle", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> - } - ) - include( - KnobComponent( - value = 0.5, - label = "Duty cycle", - minValue = 0.0, - maxValue = 1.0, - step = 2.0 / 127.0, - width = 500, - height = 600, - ) { value -> + } + ) + include( + KnobComponent( + value = 0.5, + label = "Duty cycle", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 500, + height = 600, + ) { value -> + } + ) + } + } + } + + private fun fileInputSelectHandler(event: Event) { + val target = event.target as? HTMLInputElement + val list: FileList? = target?.files + + if (list == null || list.length != 1) { + return + } + + val file: File? = list[0] + + file?.let { f: File -> + WebsocketClient.send(f) + } + } + + companion object MainViewCss : CssName() { + object MainDivCss : CssName() + object ActiveCss : CssName() + object ButtonCss : CssName() + object ButtonBarCss : CssName() + object SelectedCss : CssName() + object NoteBarCss : CssName() + object StartSplashCss : CssName() + object StartBoxCss : CssName() + object StartButtonCss : CssName() + object ControlsCss : CssName() + + init { + defineCss { + select("*") { + select("*:before") { + select("*:after") { + boxSizing(BoxSizing.borderBox) } - ) + } + } + select("html", "body") { + margin(0.px) + padding(0.px) + height(100.prc) + } + select("html", "body") { + backgroundColor(Css.currentStyle.mainBackgroundColor) + color(Css.currentStyle.mainFontColor) + + fontFamily("JetbrainsMono, monospace") + fontSize(14.px) + fontWeight(FontWeight.bold) + + //transition() + noTextSelect() + } + select("input", "textarea") { + backgroundColor(Css.currentStyle.inputBackgroundColor) + color(Css.currentStyle.mainFontColor) + border("none") + } + select(cls(ButtonCss)) { + margin(1.rem) + commonButton() + } + select(cls(ButtonBarCss)) { + margin(1.rem, 0.px) + commonButton() + } + select(cls(ActiveCss)) { + //backgroundColor(Css.currentStyle.selectedBackgroundColor) + } + select(cls(NoteBarCss)) { + minHeight(4.rem) + } + select(cls(MainDivCss)) { + margin(1.rem) + } + select("select") { + plain("appearance", "none") + border("0") + outline("0") + width(20.rem) + padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem) + backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')") + background("right 0.8em center/1.4em") + backgroundColor(Css.currentStyle.inputBackgroundColor) + color(Css.currentStyle.mainFontColor) + borderRadius(0.25.em) + } + select(cls(StartSplashCss)) { + position(Position.fixed) + left(0.px) + top(0.px) + width(100.vw) + height(100.vh) + zIndex(100) + backgroundColor(hsla(32, 0, 5, 0.65)) + + select(cls(StartBoxCss)) { + position(Position.relative) + left(25.vw) + top(25.vh) + width(50.vw) + height(50.vh) + backgroundColor(hsla(239, 50, 10, 1.0)) + borderColor(Css.currentStyle.mainFontColor) + borderWidth(2.px) + + select(cls(StartButtonCss)) { + position(Position.absolute) + left(50.prc) + top(50.prc) + transform(Transform("translate(-50%, -50%)")) + padding(1.rem) + backgroundColor(Css.currentStyle.buttonBackgroundColor) + cursor("pointer") + } + } + } + select(ControlsCss.cls()) { + display(Display.flex) + flexDirection(FlexDirection.row) + justifyContent(JustifyContent.flexStart) + alignItems(AlignItems.center) + margin(1.rem) + padding(1.rem) + backgroundColor(Css.currentStyle.mainBackgroundColor) } } } - companion object MainViewCss : CssName() { - object MainDivCss : CssName() - object ActiveCss : CssName() - object ButtonCss : CssName() - object ButtonBarCss : CssName() - object SelectedCss : CssName() - object NoteBarCss : CssName() - object StartSplashCss : CssName() - object StartBoxCss : CssName() - object StartButtonCss : CssName() - object ControlsCss : CssName() + private fun Style.commonButton() { + display(Display.inlineBlock) + padding(1.rem) + backgroundColor(Css.currentStyle.buttonBackgroundColor) + borderColor(Css.currentStyle.buttonBorderColor) + borderWidth(Css.currentStyle.buttonBorderWidth) + color(Css.currentStyle.mainFontColor) - init { - defineCss { - select("*") { - select("*:before") { - select("*:after") { - boxSizing(BoxSizing.borderBox) - } - } - } - select("html", "body") { - margin(0.px) - padding(0.px) - height(100.prc) - } - select("html", "body") { - backgroundColor(Css.currentStyle.mainBackgroundColor) - color(Css.currentStyle.mainFontColor) - - fontFamily("JetbrainsMono, monospace") - fontSize(14.px) - fontWeight(FontWeight.bold) - - //transition() - noTextSelect() - } - select("input", "textarea") { - backgroundColor(Css.currentStyle.inputBackgroundColor) - color(Css.currentStyle.mainFontColor) - border("none") - } - select(cls(ButtonCss)) { - margin(1.rem) - commonButton() - } - select(cls(ButtonBarCss)) { - margin(1.rem, 0.px) - commonButton() - } - select(cls(ActiveCss)) { - //backgroundColor(Css.currentStyle.selectedBackgroundColor) - } - select(cls(NoteBarCss)) { - minHeight(4.rem) - } - select(cls(MainDivCss)) { - margin(1.rem) - } - select("select") { - plain("appearance", "none") - border("0") - outline("0") - width(20.rem) - padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem) - backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')") - background("right 0.8em center/1.4em") - backgroundColor(Css.currentStyle.inputBackgroundColor) - color(Css.currentStyle.mainFontColor) - borderRadius(0.25.em) - } - select(cls(StartSplashCss)) { - position(Position.fixed) - left(0.px) - top(0.px) - width(100.vw) - height(100.vh) - zIndex(100) - backgroundColor(hsla(32, 0, 5, 0.65)) - - select(cls(StartBoxCss)) { - position(Position.relative) - left(25.vw) - top(25.vh) - width(50.vw) - height(50.vh) - backgroundColor(hsla(239, 50, 10, 1.0)) - borderColor(Css.currentStyle.mainFontColor) - borderWidth(2.px) - - select(cls(StartButtonCss)) { - position(Position.absolute) - left(50.prc) - top(50.prc) - transform(Transform("translate(-50%, -50%)")) - padding(1.rem) - backgroundColor(Css.currentStyle.buttonBackgroundColor) - cursor("pointer") - } - } - } - select(ControlsCss.cls()) { - display(Display.flex) - flexDirection(FlexDirection.row) - justifyContent(JustifyContent.flexStart) - alignItems(AlignItems.center) - margin(1.rem) - padding(1.rem) - backgroundColor(Css.currentStyle.mainBackgroundColor) - } - } + hover { + backgroundColor(Css.currentStyle.buttonBackgroundColor.hover()) } - - private fun Style.commonButton() { - display(Display.inlineBlock) - padding(1.rem) - backgroundColor(Css.currentStyle.buttonBackgroundColor) - borderColor(Css.currentStyle.buttonBorderColor) - borderWidth(Css.currentStyle.buttonBorderWidth) - color(Css.currentStyle.mainFontColor) - - hover { - backgroundColor(Css.currentStyle.buttonBackgroundColor.hover()) - } - and(SelectedCss.cls()) { - backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover()) - } + and(SelectedCss.cls()) { + backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover()) } } + } } \ No newline at end of file diff --git a/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/ws/WebsocketClient.kt b/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/ws/WebsocketClient.kt new file mode 100644 index 0000000..8dadb70 --- /dev/null +++ b/test-app/src/jsMain/kotlin/nl/astraeus/vst/ui/ws/WebsocketClient.kt @@ -0,0 +1,121 @@ +@file:OptIn(ExperimentalJsExport::class) + +package nl.astraeus.vst.ui.ws + +import kotlinx.browser.window +import org.khronos.webgl.ArrayBuffer +import org.w3c.dom.MessageEvent +import org.w3c.dom.WebSocket +import org.w3c.dom.events.Event +import org.w3c.files.Blob + +private const val WEBSOCKET_PART_SIZE = 65000 + +object WebsocketClient { + var websocket: WebSocket? = null + var interval: Int = 0 + + fun connect(onConnect: () -> Unit) { + close() + + websocket = + if (window.location.hostname.contains("localhost") || window.location.hostname.contains("192.168")) { + WebSocket("ws://${window.location.hostname}:${window.location.port}/ws") + } else { + WebSocket("wss://${window.location.hostname}/ws") + } + + websocket?.also { ws -> + ws.onopen = { + onOpen(ws, it) + onConnect() + } + ws.onmessage = { onMessage(ws, it) } + ws.onclose = { onClose(ws, it) } + ws.onerror = { onError(ws, it) } + } + } + + fun close() { + websocket?.close(-1, "Application closed socket.") + } + + fun onOpen( + ws: WebSocket, + event: Event + ) { + interval = window.setInterval({ + val actualWs = websocket + + if (actualWs == null) { + window.clearInterval(interval) + + console.log("Connection to the server was lost!\\nPlease try again later.") + reconnect() + } + }, 10000) + } + + fun reconnect() { + val actualWs = websocket + + if (actualWs != null) { + if (actualWs.readyState == WebSocket.OPEN) { + console.log("Connection to the server was lost!\\nPlease try again later.") + } else { + window.setTimeout({ + reconnect() + }, 1000) + } + } else { + connect {} + + window.setTimeout({ + reconnect() + }, 1000) + } + } + + fun onMessage( + ws: WebSocket, + event: Event + ) { + if (event is MessageEvent) { + val data = event.data + + if (data is String) { + console.log("Received message: $data") + } else if (data is ArrayBuffer) { + console.log("Received binary message") + } + } + } + + fun onClose( + ws: WebSocket, + event: Event + ): dynamic { + websocket = null + + return "dynamic" + } + + fun onError( + ws: WebSocket, + event: Event + ): dynamic { + console.log("Error websocket!", ws, event) + + websocket = null + + return "dynamic" + } + + fun send(message: String) { + websocket?.send(message) + } + + fun send(file: Blob) { + websocket?.send(file) + } +}