Remove KeyboardInputComponent and add file upload and WebSocket support

Deleted unused `KeyboardInputComponent`. Added file upload functionality to `MainView` with WebSocket event handling. Introduced `WebsocketClient` class for managing server connection and file transmission. Enhanced `KeyboardComponent` with configurable key rounding. Updated `WebsocketHandler` for improved binary message handling and storage.
This commit is contained in:
2025-06-10 21:08:00 +02:00
parent 8ee8f17f96
commit 5da8424c40
7 changed files with 404 additions and 237 deletions

View File

@@ -38,6 +38,7 @@ class KeyboardComponent(
initialOctave: Int = 4, initialOctave: Int = 4,
val keyboardWidth: Int = 210, val keyboardWidth: Int = 210,
val keyboardHeight: Int = keyboardWidth / 2, val keyboardHeight: Int = keyboardWidth / 2,
val rounding: Int = 4,
val onNoteDown: (Int) -> Unit = {}, val onNoteDown: (Int) -> Unit = {},
val onNoteUp: (Int) -> Unit = {} val onNoteUp: (Int) -> Unit = {}
) : Komponent() { ) : Komponent() {
@@ -222,7 +223,7 @@ class KeyboardComponent(
0, 0,
whiteKeyWidth, whiteKeyWidth,
keyboardHeight, keyboardHeight,
0, rounding,
if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name
) )
} }
@@ -238,7 +239,7 @@ class KeyboardComponent(
0, 0,
blackKeyWidth, blackKeyWidth,
blackKeyHeight, blackKeyHeight,
0, rounding,
if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name
) )
} }

View File

@@ -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()) {
}
}
}
}
}

View File

@@ -45,12 +45,12 @@ fun SVG.rect(
y: Int, y: Int,
width: Int, width: Int,
height: Int, height: Int,
rx: Int, rounding: Int,
cls: String cls: String,
) { ) {
this.unsafe { this.unsafe {
+ """ + """
<rect class="$cls" x="$x" y="$y" width="$width" height="$height" rx="$rx" /> <rect class="$cls" x="$x" y="$y" width="$width" height="$height" rx="$rounding" rx="$rounding" />
""".trimIndent() """.trimIndent()
} }
} }

View File

@@ -13,6 +13,7 @@ import nl.astraeus.vst.base.db.Database
import nl.astraeus.vst.base.db.PatchDao import nl.astraeus.vst.base.db.PatchDao
import nl.astraeus.vst.base.db.PatchEntity import nl.astraeus.vst.base.db.PatchEntity
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.security.MessageDigest import java.security.MessageDigest
@@ -22,7 +23,7 @@ class WebsocketHandler(
companion object { companion object {
// Ensure the data directory exists // Ensure the data directory exists
private val dataDir = File(Settings.dataDir).apply { private val filesDir = File(Settings.dataDir, "files").apply {
if (!exists()) { if (!exists()) {
mkdirs() mkdirs()
} }
@@ -31,7 +32,7 @@ class WebsocketHandler(
fun fileExists(hash: String): Boolean { fun fileExists(hash: String): Boolean {
check(hash.length == 64) { "Hash must be 64 characters long" } check(hash.length == 64) { "Hash must be 64 characters long" }
var currentDir = dataDir var currentDir = filesDir
var remaining = hash var remaining = hash
while(remaining.length > 8) { while(remaining.length > 8) {
val subDir = remaining.substring(0, 8) val subDir = remaining.substring(0, 8)
@@ -49,7 +50,7 @@ class WebsocketHandler(
fun getFileFromHash(hash: String): File { fun getFileFromHash(hash: String): File {
check(hash.length == 64) { "Hash must be 64 characters long" } check(hash.length == 64) { "Hash must be 64 characters long" }
var currentDir = dataDir var currentDir = filesDir
var remaining = hash var remaining = hash
while(remaining.length > 8) { while(remaining.length > 8) {
val subDir = remaining.substring(0, 8) val subDir = remaining.substring(0, 8)
@@ -98,7 +99,11 @@ class WebsocketHandler(
PatchDao.insert(PatchEntity(0, patchId, value)) 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) val patchEntity = PatchDao.findById(patchId)
if (patchEntity != null) { 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) val file = getFileFromHash(hash)
if (file.exists() && file.isFile) { if (file.exists() && file.isFile) {
val bytes = file.readBytes() 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, // Process binary message: create hash from binary, save file in data/files/ directory,
// sub directories are 5 characters of the hash per directory // sub directories are 5 characters of the hash per directory
if (channel != null && message != null) { if (channel != null && message != null) {
try { try {
// Get the binary data // Get the binary data
val pooled = message.data val pooled = message.data
val buffer = pooled.resource[0] // Get the first ByteBuffer val resources = pooled.resource
// Convert ByteBuffer to ByteArray // Collect all bytes from all buffers
val bytes = ByteArray(buffer.remaining()) var totalSize = 0
buffer.get(bytes)
// Free the pooled resource // First pass: collect all bytes and calculate total size
pooled.free() for (buffer in resources) {
totalSize += buffer.remaining()
}
// Create hash from binary data // Combine all bytes into a single array
val hash = createHashFromBytes(bytes) 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 // Save file in data/files/ directory with subdirectories
val file = getFileFromHash(hash) 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 // Send the hash back to the client
WebSockets.sendText("BINARY_SAVED\n$hash", channel, null) WebSockets.sendText("BINARY_SAVED\n$hash", channel, null)
// Free the pooled resource after processing
pooled.free()
} catch (e: Exception) { } catch (e: Exception) {
WebSockets.sendText("ERROR\n${e.message}", channel, null) WebSockets.sendText("ERROR\n${e.message}", channel, null)
} }

View File

@@ -5,6 +5,7 @@ import nl.astraeus.komp.Komponent
import nl.astraeus.komp.UnsafeMode import nl.astraeus.komp.UnsafeMode
import nl.astraeus.vst.ui.css.CssSettings import nl.astraeus.vst.ui.css.CssSettings
import nl.astraeus.vst.ui.view.MainView import nl.astraeus.vst.ui.view.MainView
import nl.astraeus.vst.ui.ws.WebsocketClient
val mainView: MainView by lazy { val mainView: MainView by lazy {
MainView() MainView()
@@ -16,4 +17,8 @@ fun main() {
Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY
Komponent.create(document.body!!, mainView) Komponent.create(document.body!!, mainView)
WebsocketClient.connect {
println("Connected")
}
} }

View File

@@ -2,11 +2,15 @@
package nl.astraeus.vst.ui.view package nl.astraeus.vst.ui.view
import kotlinx.html.InputType
import kotlinx.html.div import kotlinx.html.div
import kotlinx.html.h1 import kotlinx.html.h1
import kotlinx.html.hr import kotlinx.html.hr
import kotlinx.html.input
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction import kotlinx.html.js.onClickFunction
import kotlinx.html.option import kotlinx.html.option
import kotlinx.html.org.w3c.dom.events.Event
import kotlinx.html.select import kotlinx.html.select
import kotlinx.html.span import kotlinx.html.span
import kotlinx.html.style 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.Css.noTextSelect
import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.css.CssName
import nl.astraeus.vst.ui.css.hover 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() { class MainView : Komponent() {
private var messages: MutableList<String> = ArrayList() private var messages: MutableList<String> = ArrayList()
@@ -113,6 +122,16 @@ class MainView : Komponent() {
} }
*/ */
} }
span {
+"Upload file: "
input {
type = InputType.file
onChangeFunction = {
fileInputSelectHandler(it)
requestUpdate()
}
}
}
} }
} }
div { div {
@@ -134,209 +153,226 @@ class MainView : Komponent() {
} }
hr {} hr {}
div { div {
include(KeyboardComponent( include(
keyboardWidth = keyboardWidth, KeyboardComponent(
keyboardHeight = keyboardWidth / 2, keyboardWidth = keyboardWidth,
onNoteDown = { println("Note down: $it") }, keyboardHeight = keyboardWidth / 2,
onNoteUp = { println("Note up: $it") }, onNoteDown = { println("Note down: $it") },
)) onNoteUp = { println("Note up: $it") },
)
)
} }
/* /*
div { div {
span(ButtonBarCss.name) { span(ButtonBarCss.name) {
+"SAVE" +"SAVE"
onClickFunction = { onClickFunction = {
val patch = VstChipWorklet.save().copy( val patch = VstChipWorklet.save().copy(
midiId = Midi.currentInput?.id ?: "", midiId = Midi.currentInput?.id ?: "",
midiName = Midi.currentInput?.name ?: "" midiName = Midi.currentInput?.name ?: ""
) )
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}") WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstChipWorklet.postDirectlyToWorklet(
TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0)
.data.buffer.data
)
}
} }
} }
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstChipWorklet.postDirectlyToWorklet(
TimedMidiMessage(getCurrentTime(), (0xb0 + midiChannel).toByte(), 123, 0)
.data.buffer.data
)
}
}
}
*/ */
div(ControlsCss.name) { div(ControlsCss.name) {
include( include(
ExpKnobComponent( ExpKnobComponent(
value = 0.001, value = 0.001,
label = "Volume", label = "Volume",
minValue = 0.001, minValue = 0.001,
maxValue = 1.0, maxValue = 1.0,
step = 10.0 / 127.0, step = 10.0 / 127.0,
width = 100, width = 100,
height = 120, height = 120,
) { value -> ) { value ->
} }
) )
include( include(
KnobComponent( KnobComponent(
value = 0.5, value = 0.5,
label = "Duty cycle", label = "Duty cycle",
minValue = 0.0, minValue = 0.0,
maxValue = 1.0, maxValue = 1.0,
step = 2.0 / 127.0, step = 2.0 / 127.0,
width = 100, width = 100,
height = 120, height = 120,
) { value -> ) { value ->
} }
) )
include( include(
KnobComponent( KnobComponent(
value = 0.5, value = 0.5,
label = "Duty cycle", label = "Duty cycle",
minValue = 0.0, minValue = 0.0,
maxValue = 1.0, maxValue = 1.0,
step = 2.0 / 127.0, step = 2.0 / 127.0,
width = 500, width = 500,
height = 600, height = 600,
) { value -> ) { 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() { private fun Style.commonButton() {
object MainDivCss : CssName() display(Display.inlineBlock)
object ActiveCss : CssName() padding(1.rem)
object ButtonCss : CssName() backgroundColor(Css.currentStyle.buttonBackgroundColor)
object ButtonBarCss : CssName() borderColor(Css.currentStyle.buttonBorderColor)
object SelectedCss : CssName() borderWidth(Css.currentStyle.buttonBorderWidth)
object NoteBarCss : CssName() color(Css.currentStyle.mainFontColor)
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : CssName()
object ControlsCss : CssName()
init { hover {
defineCss { backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
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)
}
}
} }
and(SelectedCss.cls()) {
private fun Style.commonButton() { backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover())
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())
}
} }
} }
}
} }

View File

@@ -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)
}
}