App Kotlin Multiplatform (Android/iOS/Desktop) con Compose Multiplatform: controllo intonazione (YIN), pratica guidata, metronomo, ear training, sfide, scansione spartiti via AI, spartiti bundled e pentagramma con glifi tipografici, travature, battute e spaziatura proporzionale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
6.8 KiB
Kotlin
171 lines
6.8 KiB
Kotlin
package com.notemusicali.ui.components
|
|
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.drawText
|
|
import androidx.compose.ui.text.rememberTextMeasurer
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import com.notemusicali.music.MusicalNote
|
|
|
|
/**
|
|
* Shows a window of notes around the current index, with the current note highlighted.
|
|
* [windowSize] controls how many notes are visible (odd number works best).
|
|
*/
|
|
@Composable
|
|
fun StaffContextView(
|
|
notes: List<MusicalNote>,
|
|
currentIndex: Int,
|
|
modifier: Modifier = Modifier,
|
|
windowSize: Int = 7,
|
|
lightTheme: Boolean = false,
|
|
beats: Int = 4,
|
|
beatType: Int = 4,
|
|
) {
|
|
if (notes.isEmpty()) return
|
|
|
|
val textMeasurer = rememberTextMeasurer()
|
|
|
|
Canvas(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.height(140.dp),
|
|
) {
|
|
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
|
|
val dimNoteColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.35f)
|
|
val playedNoteColor = if (lightTheme) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f)
|
|
val currentNoteColor = Color(0xFFFFD54F)
|
|
val clefColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.8f)
|
|
|
|
val lineSpacing = size.height / 10f
|
|
val staffBottom = size.height / 2f + lineSpacing * 2f
|
|
val halfSpacing = lineSpacing / 2f
|
|
val leftMargin = size.width * 0.06f
|
|
val rightMargin = size.width * 0.02f
|
|
|
|
val useTreble = StaffLayout.useTrebleClef(notes)
|
|
|
|
// Staff lines
|
|
for (i in 0..4) {
|
|
val y = staffBottom - i * lineSpacing
|
|
drawLine(lineColor, Offset(leftMargin, y), Offset(size.width - rightMargin, y), 1.5f)
|
|
}
|
|
|
|
// Determine visible window
|
|
val half = windowSize / 2
|
|
val startIdx = (currentIndex - half).coerceAtLeast(0)
|
|
val endIdx = (startIdx + windowSize).coerceAtMost(notes.size)
|
|
val visibleNotes = notes.subList(startIdx, endIdx)
|
|
|
|
// Clef (+ time signature when the window includes the start of the piece)
|
|
var cursorX = leftMargin + 2f
|
|
cursorX += drawClef(textMeasurer, useTreble, cursorX, staffBottom, lineSpacing, clefColor)
|
|
if (startIdx == 0) {
|
|
cursorX += 6f
|
|
cursorX += drawTimeSignature(textMeasurer, beats, beatType, cursorX, staffBottom, lineSpacing, clefColor)
|
|
}
|
|
|
|
val noteAreaStart = cursorX + 10f
|
|
val noteAreaEnd = size.width - rightMargin - 8f
|
|
val noteAreaWidth = noteAreaEnd - noteAreaStart
|
|
|
|
val xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth)
|
|
// Beam groups on the full sequence, clipped to the visible window (window-local indices)
|
|
val groups = StaffLayout.beamGroups(notes, beats, beatType)
|
|
.mapNotNull { g ->
|
|
val from = maxOf(g.first, startIdx)
|
|
val to = minOf(g.last, endIdx - 1)
|
|
if (to - from >= 1) (from - startIdx)..(to - startIdx) else null
|
|
}
|
|
val grouped = groups.flatMap { it }.toSet()
|
|
val bars = StaffLayout.measurePositions(notes, beats, beatType).toSet()
|
|
|
|
val headHalf = staffHeadHalfWidth(lineSpacing)
|
|
val ledgerHalf = lineSpacing * 0.8f
|
|
|
|
visibleNotes.forEachIndexed { vi, note ->
|
|
val actualIdx = startIdx + vi
|
|
val nx = xs[vi]
|
|
|
|
val isCurrent = actualIdx == currentIndex
|
|
val isPlayed = actualIdx < currentIndex
|
|
val color = when {
|
|
isCurrent -> currentNoteColor
|
|
isPlayed -> playedNoteColor
|
|
else -> dimNoteColor
|
|
}
|
|
|
|
val relPos = StaffLayout.relativePosition(note, useTreble)
|
|
val ny = staffBottom - relPos * halfSpacing
|
|
|
|
// Ledger lines
|
|
var lp = -2
|
|
while (lp >= relPos) {
|
|
val ly = staffBottom - lp * halfSpacing
|
|
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
|
|
lp -= 2
|
|
}
|
|
lp = 10
|
|
while (lp <= relPos) {
|
|
val ly = staffBottom - lp * halfSpacing
|
|
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
|
|
lp += 2
|
|
}
|
|
|
|
// Note head
|
|
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, color)
|
|
|
|
// Stem + flags (only for notes not part of a beamed group)
|
|
if (vi !in grouped) {
|
|
val stemUp = relPos < 4
|
|
val stemTip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, color)
|
|
stemTip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, color) }
|
|
}
|
|
|
|
// Sharp
|
|
drawAccidentalGlyph(note, nx - headHalf - lineSpacing * 0.5f, ny, lineSpacing * 0.35f, color)
|
|
|
|
// Barline between this note and the next
|
|
if (actualIdx in bars && vi < visibleNotes.lastIndex) {
|
|
drawBarline((xs[vi] + xs[vi + 1]) / 2f, staffBottom, lineSpacing, lineColor)
|
|
}
|
|
|
|
// Current note highlight circle
|
|
if (isCurrent) {
|
|
drawCircle(currentNoteColor.copy(alpha = 0.15f), radius = lineSpacing * 2f, center = Offset(nx, ny))
|
|
}
|
|
|
|
// Note name under current
|
|
if (isCurrent) {
|
|
val labelStyle = TextStyle(color = currentNoteColor, fontSize = 12.sp)
|
|
val labelResult = textMeasurer.measure(note.fullName, labelStyle)
|
|
drawText(labelResult, topLeft = Offset(nx - labelResult.size.width / 2f, staffBottom + lineSpacing * 1.2f))
|
|
}
|
|
}
|
|
|
|
// Beamed groups
|
|
val currentVi = currentIndex - startIdx
|
|
groups.forEach { group ->
|
|
val gxs = group.map { xs[it] }
|
|
val relPositions = group.map { StaffLayout.relativePosition(visibleNotes[it], useTreble) }
|
|
val gys = relPositions.map { staffBottom - it * halfSpacing }
|
|
val gdurations = group.map { visibleNotes[it].duration }
|
|
val groupColor = if (currentVi in group) currentNoteColor else dimNoteColor
|
|
drawBeamedGroup(
|
|
xs = gxs,
|
|
ys = gys,
|
|
durations = gdurations,
|
|
stemUp = StaffLayout.stemUpForGroup(relPositions),
|
|
lineSpacing = lineSpacing,
|
|
color = groupColor,
|
|
)
|
|
}
|
|
}
|
|
}
|