Files
vst-ui-base/src/jsMain/kotlin/nl/astraeus/vst/ui/components/KeyboardComponent.kt
rnentjes 5da8424c40 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.
2025-06-10 21:08:00 +02:00

364 lines
11 KiB
Kotlin

package nl.astraeus.vst.ui.components
import kotlinx.html.SVG
import kotlinx.html.div
import kotlinx.html.js.onMouseDownFunction
import kotlinx.html.js.onMouseLeaveFunction
import kotlinx.html.js.onMouseUpFunction
import kotlinx.html.style
import kotlinx.html.svg
import nl.astraeus.css.properties.AlignItems
import nl.astraeus.css.properties.Display
import nl.astraeus.css.properties.FlexDirection
import nl.astraeus.css.properties.FontWeight
import nl.astraeus.css.properties.JustifyContent
import nl.astraeus.css.properties.Position
import nl.astraeus.css.properties.TextAlign
import nl.astraeus.css.properties.prc
import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem
import nl.astraeus.css.style.cls
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.ui.css.Css
import nl.astraeus.vst.ui.css.Css.defineCss
import nl.astraeus.vst.ui.css.CssId
import nl.astraeus.vst.ui.css.CssName
import nl.astraeus.vst.ui.util.height
import nl.astraeus.vst.ui.util.rect
import nl.astraeus.vst.ui.util.width
import org.w3c.dom.events.MouseEvent
/**
* The keyboard component shows 1 octabe of a (piano) keyboard and
* calls the noteDown and noteUp methods when the keys are clicked
*/
class KeyboardComponent(
val title: String = "Keyboard",
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() {
// Define a data class for keyboard state
data class KeyboardState(
val octave: Int,
val pressedNotes: Set<Int> = emptySet()
)
// Use immutable state
private var state = KeyboardState(initialOctave)
// Current octave with range validation
var octave: Int
get() = state.octave
set(value) {
updateOctave(value)
}
// Update state with functions that return new state
private fun updateOctave(newOctave: Int) {
state = state.copy(octave = newOctave.coerceIn(MIN_OCTAVE, MAX_OCTAVE))
requestUpdate()
}
// MIDI note numbers for one octave
private val whiteKeys = BASE_WHITE_KEYS
private val blackKeys = BASE_BLACK_KEYS
// Key dimensions
private val whiteKeyWidth = keyboardWidth / WHITE_KEYS_PER_OCTAVE
private val blackKeyWidth = (keyboardWidth / BLACK_KEY_WIDTH_DIVISOR)
private val blackKeyHeight = keyboardHeight * BLACK_KEY_HEIGHT_PERCENTAGE / 100
// Calculate positions for black keys
private val blackKeyPositions = listOf(
whiteKeyWidth - blackKeyWidth/2,
whiteKeyWidth*2 - blackKeyWidth/2,
whiteKeyWidth*4 - blackKeyWidth/2,
whiteKeyWidth*5 - blackKeyWidth/2,
whiteKeyWidth*6 - blackKeyWidth/2
)
fun noteDown(midiNote: Int) {
state = state.copy(pressedNotes = state.pressedNotes + midiNote)
onNoteDown(midiNote)
requestUpdate()
}
fun noteUp(midiNote: Int) {
state = state.copy(pressedNotes = state.pressedNotes - midiNote)
onNoteUp(midiNote)
requestUpdate()
}
private fun releaseAllNotes() {
// Create a copy of the set to avoid concurrent modification
val notesToRelease = state.pressedNotes.toSet()
for (note in notesToRelease) {
noteUp(note)
}
// Just to be safe, clear all pressed notes
state = state.copy(pressedNotes = emptySet())
}
private fun getOctaveOffset(): Int = (octave - OCTAVE_BASE) * NOTES_PER_OCTAVE
private fun getMidiNoteFromMousePosition(x: Double, y: Double): Int? {
// Check if click is on a black key (black keys are on top of white keys)
if (y <= blackKeyHeight) {
for (j in 0 until BLACK_KEYS_PER_OCTAVE) {
if (x >= blackKeyPositions[j] && x <= blackKeyPositions[j] + blackKeyWidth) {
return blackKeys[j] + getOctaveOffset()
}
}
// If no black key was pressed, check for white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0 until WHITE_KEYS_PER_OCTAVE) {
return whiteKeys[keyIndex] + getOctaveOffset()
}
} else {
// If y > blackKeyHeight, it's definitely a white key
val keyIndex = (x / whiteKeyWidth).toInt()
if (keyIndex in 0 until WHITE_KEYS_PER_OCTAVE) {
return whiteKeys[keyIndex] + getOctaveOffset()
}
}
return null
}
override fun HtmlBuilder.render() {
div(KeyboardCls.name) {
style = "width: ${keyboardWidth}px; height: ${keyboardHeight + KEYBOARD_CONTROLS_HEIGHT}px"
onMouseLeaveFunction = { event ->
if (event is MouseEvent) {
releaseAllNotes()
event.preventDefault()
}
}
div(KeyboardControlsCls.name) {
// Decrease octave button
div(OctaveButtonCls.name) {
style = "width: ${whiteKeyWidth}px"
+"<"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
updateOctave(octave - 1)
event.preventDefault()
}
}
}
// Title and octave display container
div(KeyboardInfoCls.name) {
div(KeyboardTitleCls.name) {
// Show title of the keyboard
+title
}
div(KeyboardOctaveCls.name) {
// Show current octave the piano is being played at
+"Octave: $octave"
}
}
// Increase octave button
div(OctaveButtonCls.name) {
style = "width: ${whiteKeyWidth}px"
+">"
onMouseDownFunction = { event ->
if (event is MouseEvent) {
updateOctave(octave + 1)
event.preventDefault()
}
}
}
}
div(KeyboardKeysCls.name) {
// Draw the keyboard with SVG, and mousedown and mouseup methods
// that call noteDown and noteUp
svg {
width(keyboardWidth)
height(keyboardHeight)
// Define mouse event handlers at the SVG level
onMouseDownFunction = { event ->
if (event is MouseEvent) {
getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteDown(it) }
event.preventDefault()
}
}
onMouseUpFunction = { event ->
if (event is MouseEvent) {
getMidiNoteFromMousePosition(event.offsetX, event.offsetY)?.let { noteUp(it) }
event.preventDefault()
}
}
// Draw white keys
this.renderWhiteKeys()
// Draw black keys
this.renderBlackKeys()
}
}
}
}
// Render white keys
private fun SVG.renderWhiteKeys() {
for (i in 0 until WHITE_KEYS_PER_OCTAVE) {
val midiNote = whiteKeys[i] + getOctaveOffset()
val isPressed = state.pressedNotes.contains(midiNote)
rect(
i * whiteKeyWidth,
0,
whiteKeyWidth,
keyboardHeight,
rounding,
if (isPressed) WhiteKeyPressedCls.name else WhiteKeyCls.name
)
}
}
// Render black keys
private fun SVG.renderBlackKeys() {
for (i in 0 until BLACK_KEYS_PER_OCTAVE) {
val midiNote = blackKeys[i] + getOctaveOffset()
val isPressed = state.pressedNotes.contains(midiNote)
rect(
blackKeyPositions[i],
0,
blackKeyWidth,
blackKeyHeight,
rounding,
if (isPressed) BlackKeyPressedCls.name else BlackKeyCls.name
)
}
}
companion object : CssId("keyboard") {
// CSS class names
object KeyboardCls : CssName()
object KeyboardControlsCls : CssName()
object KeyboardInfoCls : CssName()
object KeyboardTitleCls : CssName()
object KeyboardOctaveCls : CssName()
object KeyboardKeysCls : CssName()
object OctaveButtonCls : CssName()
object WhiteKeyCls : CssName()
object BlackKeyCls : CssName()
object WhiteKeyPressedCls : CssName()
object BlackKeyPressedCls : CssName()
// Constants
private const val OCTAVE_BASE = 5
private const val NOTES_PER_OCTAVE = 12
private const val BLACK_KEY_HEIGHT_PERCENTAGE = 60
private const val MIN_OCTAVE = 0
private const val MAX_OCTAVE = 9
private const val WHITE_KEYS_PER_OCTAVE = 7
private const val BLACK_KEYS_PER_OCTAVE = 5
private const val BLACK_KEY_WIDTH_DIVISOR = 9
private const val KEYBOARD_CONTROLS_HEIGHT = 60
// MIDI note numbers for C5 to B5 (one octave)
private val BASE_WHITE_KEYS = listOf(60, 62, 64, 65, 67, 69, 71) // C, D, E, F, G, A, B
private val BASE_BLACK_KEYS = listOf(61, 63, 66, 68, 70) // C#, D#, F#, G#, A#
init {
defineCss {
select(cls(KeyboardCls)) {
position(Position.relative)
margin(5.px)
select(cls(KeyboardControlsCls)) {
position(Position.relative)
display(Display.flex)
flexDirection(FlexDirection.row)
justifyContent(JustifyContent.spaceBetween)
alignItems(AlignItems.center)
width(100.prc)
height(50.px)
marginBottom(10.px)
}
select(cls(KeyboardInfoCls)) {
display(Display.flex)
flexDirection(FlexDirection.column)
alignItems(AlignItems.center)
justifyContent(JustifyContent.center)
flex("1")
}
select(cls(KeyboardTitleCls)) {
textAlign(TextAlign.center)
fontSize(1.2.rem)
color(Css.currentStyle.mainFontColor)
}
select(cls(KeyboardOctaveCls)) {
textAlign(TextAlign.center)
fontSize(1.0.rem)
color(Css.currentStyle.mainFontColor)
marginTop(5.px)
}
select(cls(OctaveButtonCls)) {
height(50.px)
plain("background-color", Css.currentStyle.buttonBackgroundColor.toString())
color(Css.currentStyle.mainFontColor)
border("1px solid ${Css.currentStyle.buttonBorderColor}")
textAlign(TextAlign.center)
lineHeight(50.px)
fontSize(1.5.rem)
fontWeight(FontWeight.bold)
cursor("pointer")
plain("-webkit-touch-callout", "none")
plain("-webkit-user-select", "none")
plain("-moz-user-select", "none")
plain("-ms-user-select", "none")
plain("user-select", "none")
}
select(cls(KeyboardKeysCls)) {
position(Position.relative)
}
}
select(cls(WhiteKeyCls)) {
plain("fill", "#FFFFFF")
plain("stroke", "#000000")
plain("stroke-width", "1")
}
select(cls(WhiteKeyPressedCls)) {
plain("fill", "#E6E6E6") // 10% darker than white
plain("stroke", "#000000")
plain("stroke-width", "1")
}
select(cls(BlackKeyCls)) {
plain("fill", "#000000")
plain("stroke", "#000000")
plain("stroke-width", "1")
}
select(cls(BlackKeyPressedCls)) {
plain("fill", "#333333") // 10% lighter than black
plain("stroke", "#000000")
plain("stroke-width", "1")
}
}
}
}
}