Implement smart B placementfor A-B looping based measure - windows

This commit is contained in:
Hasinjato 2026-09-07 14:09:40 +03:00
parent a628984986
commit 60c11b7ac8
6 changed files with 205 additions and 59 deletions

View file

@ -17,8 +17,8 @@ expect class FMediaPlayer(filename: String, sharedScreenModel: SharedScreenModel
fun seekTo(position: Long)
fun seekToGrid(gridIndex: Int)
fun setVolume(level: Float)
fun setPointA()
fun setPointB()
fun setPointA(grid: Int)
fun setPointB(grid: Int)
fun clearLoop()
fun setTempo(bpm: Float)
fun getCurrentBPM(): Float

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.window.Popup
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
import cafe.adriel.voyager.navigator.internal.BackHandler
import com.russhwolf.settings.Settings
import feufaro.composeapp.generated.resources.Emmentaler
import feufaro.composeapp.generated.resources.PTSerif_Bold
import feufaro.composeapp.generated.resources.PT_Serif_Bold_Italic
@ -59,6 +60,7 @@ import mg.dot.feufaro.TUODetailDialog
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.getPathValidator
import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.provideSettings
import mg.dot.feufaro.ui.rememberFileSaveLauncher
import mg.dot.feufaro.viewmodel.MidiMarkers
import mg.dot.feufaro.viewmodel.SolfaScreenModel
@ -283,18 +285,19 @@ fun TimeUnitComposable(
isCurrentBlock: Boolean = false,
parentFocusRequester: FocusRequester? = null,
transpositionInterval: Int,
hairpinRange: HairpinRange? = null
hairpinRange: HairpinRange? = null,
isInABrange: Boolean = false
) {
val col = if (tuo.getNum() % 2 == 0) MaterialTheme.colorScheme.tertiary.copy(alpha = 0.05f) else MaterialTheme.colorScheme.error.copy(alpha = 0.05f)
val currentDensity = LocalDensity.current
val colorUnderline = MaterialTheme.colorScheme.onSecondary
val animatedColor = if (gridActive) {
animateColorAsState(
val animatedColor = when {
gridActive -> animateColorAsState(
targetValue = MaterialTheme.colorScheme.secondary.copy(alpha = 1f),
animationSpec = tween(durationMillis = 100)
).value
} else {
col
isInABrange -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.25f)
else -> col
}
val focusRequesters = remember { List(4) { FocusRequester() } }
@ -636,6 +639,15 @@ fun LazyVerticalGridTUO(
val platform = getPlatform()
val isAndroid = platform.name.startsWith("Android")
val loopingState by sharedScreenModel.loopingState.collectAsState()
val activeIndex by sharedScreenModel.activeIndex.collectAsState()
val loopState by produceState(Triple(-1L, -1L, false), sharedScreenModel.mediaPlayer) {
while (true) {
value = sharedScreenModel.mediaPlayer?.getLoopState() ?: Triple(-1L, -1L, false)
kotlinx.coroutines.delay(100)
}
}
val prefs: Settings = provideSettings()
Column(
modifier = Modifier
.fillMaxWidth()
@ -999,6 +1011,17 @@ fun LazyVerticalGridTUO(
Row(modifier = Modifier.fillMaxWidth()) {
measureTUOs.forEachIndexed { indexInMeasure, oneTUO ->
val globalIndex = (measureIndex * gridColumnCount) + indexInMeasure
val pointA = loopingState.first
val pointB = loopingState.second
val isInABrange = if (pointA != -1 && pointB != -1) {
val min = minOf(pointA, pointB)
val max = maxOf(pointA, pointB)
globalIndex in min..max
} else {
false
}
val isActive = (globalIndex == activeRowIndex)
val isSelectedByKeyboard = (globalIndex == selectedGridIndex)
@ -1083,8 +1106,22 @@ fun LazyVerticalGridTUO(
selectedTUO = oneTUO
selectedIndex = globalIndex
showEditDialog = true
} else {
val isAtStart = loopState.first == -1L
val isWaitingForB = loopState.first != -1L && !loopState.third
if (isWaitingForB) {
val enableSmartB = prefs.getBoolean("smartBpoint", true)
val smartIndex = if (enableSmartB) {
sharedScreenModel.calculateSmartPointB(globalIndex, isZeroIndexed = true)
} else {
globalIndex
}
sharedScreenModel.mediaPlayer?.setPointB(smartIndex)
} else {
sharedScreenModel.seekToGrid(globalIndex)
sharedScreenModel.updateActiveIndexByIndex(globalIndex)
}
}
},
onDoubleTap = { offset ->
@ -1108,7 +1145,8 @@ fun LazyVerticalGridTUO(
isCurrentBlock = isSelectedByKeyboard && editMode,
parentFocusRequester = focusRequester,
transpositionInterval = currentInterval,
hairpinRange = hairpinByEndBlock[oneTUO.numBlock]
hairpinRange = hairpinByEndBlock[oneTUO.numBlock],
isInABrange = isInABrange
)
if (showContextualMenu && selectedIndex == globalIndex) {

View file

@ -368,8 +368,8 @@ fun MidiControlPanel(
OutlinedButton(
onClick = {
when {
isAtStart -> mediaPlayer.setPointA()
isWaitingForB -> mediaPlayer.setPointB()
isAtStart -> mediaPlayer.setPointA(sharedScreenModel.activeIndex.value)
isWaitingForB -> mediaPlayer.setPointB(sharedScreenModel.activeIndex.value)
isLooping -> {
mediaPlayer.clearLoop()
}

View file

@ -51,6 +51,7 @@ fun Settings(
) {
val prefs: Settings = provideSettings()
var hasCountIn by remember { mutableStateOf(prefs.getBoolean("isCountIn", true)) }
var isSmartPointBLoop by remember { mutableStateOf(prefs.getBoolean("smartBpoint", true)) }
var isDynamicEnabled by remember { mutableStateOf(Dynamic.isGloballyEnabled) }
var refreshTrigger by remember { mutableStateOf(0) }
val isFullScreenEnabled by sharedScreenModel.isFullScreen.collectAsState()
@ -698,6 +699,7 @@ fun Settings(
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
@ -716,7 +718,7 @@ fun Settings(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if(!isAndroid) {
if (!isAndroid) {
val countInStat = if (hasCountIn) "Désactiver" else "Activer"
Text(
text = countInStat,
@ -740,6 +742,53 @@ fun Settings(
)
}
}
HorizontalDivider(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
modifier = Modifier.padding(vertical = 4.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "A-B lopping intelligent",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if(!isAndroid) {
val stat = if (isSmartPointBLoop) "Désactiver" else "Activer"
Text(
text = stat,
style = MaterialTheme.typography.bodyMedium
)
}
Switch(
checked = isSmartPointBLoop,
onCheckedChange = { enabled ->
isSmartPointBLoop = enabled
prefs.putBoolean("smartBpoint", enabled)
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
uncheckedBorderColor = MaterialTheme.colorScheme.outline
),
modifier = Modifier.size(45.dp)
)
}
}
}
}
}
}

View file

@ -444,6 +444,36 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_activeIndex.value = clamped
}
}
private val _loopingState = MutableStateFlow(Pair(-1, -1))
val loopingState: StateFlow<Pair<Int, Int>> = _loopingState.asStateFlow()
fun updateLoopingState(a: Int, b: Int) {
_loopingState.value = Pair(a, b)
}
fun calculateSmartPointB(rawPointB: Int, isZeroIndexed: Boolean = false): Int {
val currentMeasure = _measure.value
val numerator = currentMeasure.substringBefore("/").toIntOrNull() ?: 4
val multiple = when (numerator) {
2, 4, 8, 10 -> 4
3, 6, 9, 12 -> 3
else -> 2
}
val remainder = rawPointB % multiple
var finalPointB = if (remainder == 0) {
rawPointB
} else {
rawPointB + (multiple - remainder)
}
if (isZeroIndexed) {
finalPointB -= 1
}
return finalPointB
}
private val _gridCount = MutableStateFlow(0)
val gridCount: StateFlow<Int> = _gridCount.asStateFlow()
@ -681,7 +711,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_isPos.value = false
player.play()
player.setVolume(_volumeLevel.value)
if(currentPos.value == 0f) {
if (currentPos.value == 0f && _loopingState.value.first == -1) {
player.seekTo(0)
}
}
@ -749,6 +779,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
setTranspositionInterval(0)
setTranspToC(0)
setSilentDurBefr(0)
updateLoopingState(0, 0)
_midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList()
_searchTitle.value = ""

View file

@ -639,7 +639,7 @@ actual class FMediaPlayer actual constructor(
//Soufflet
step.hairPin != null -> {
val resolution = sequencer?.sequence?.resolution?.toLong() ?: 480L
val resolution = sequencer?.sequence?.resolution?.toLong() ?: 60L
val gridDistance = (step.hairPinEndGrid - step.gridIndex).coerceAtLeast(1)
val beatDurationMs = (60_000L / targetBpm).toLong()
val durationMs = (gridDistance * beatDurationMs).coerceIn(200L, 5000L)
@ -717,8 +717,8 @@ actual class FMediaPlayer actual constructor(
syncJob = playerScope.launch(Dispatchers.Default) {
while (isActive) {
if (sequencer?.isRunning == true) {
val resolution = sequencer?.sequence?.resolution?.toLong() ?: 480L
val gridIndex = (sequencer!!.tickPosition / resolution).toInt()
val gridStep = 60L
val gridIndex = (sequencer!!.tickPosition / gridStep).toInt()
sharedScreenModel.updateActiveIndexByIndex(gridIndex)
}
delay(20)
@ -726,8 +726,8 @@ actual class FMediaPlayer actual constructor(
}
}
actual fun seekToGrid(gridIndex: Int) {
val resolution = sequencer?.sequence?.resolution?.toDouble() ?: 480.0
val tick = (gridIndex.toDouble() * resolution).toLong()
val gridStep = 60.0
val tick = (gridIndex.toDouble() * gridStep).toLong()
sequencer?.tickPosition = tick
applyBpm()
@ -746,7 +746,17 @@ actual class FMediaPlayer actual constructor(
}
actual fun play(){
if (sequencer?.isOpen == true) {
val currentTick = sequencer?.tickPosition ?: 0L
var currentTick = sequencer?.tickPosition ?: 0L
if (isLoopingAB && pointA != -1L) {
if (currentTick < pointA || currentTick >= pointB) {
val gridStep = 60.0
val gridIndexA = (pointA / gridStep).toInt()
seekToGrid(gridIndexA)
currentTick = sequencer?.tickPosition ?: pointA
}
}
if (currentTick == 0L) {
resetNavigationFlags()
/*targetBpm = initialBpm
@ -754,7 +764,11 @@ actual class FMediaPlayer actual constructor(
boundModel?.setBpmFlow(initialBpm)*/
}
val countInEnable = prefs.getBoolean("isCountIn", true)
if (currentTick == 0L && countInEnable) {
val isStartingFromBeginning = (currentTick == 0L)
val isStartingFromLoopA = (isLoopingAB && currentTick == pointA)
if ((isStartingFromBeginning || isStartingFromLoopA) && countInEnable) {
playCountIn()
} else {
startMidiPlayback()
@ -928,12 +942,24 @@ actual class FMediaPlayer actual constructor(
// println("la volume $level")
}
actual fun setPointA() {
pointA = sequencer?.tickPosition ?: 0L
actual fun setPointA(grid: Int) {
val gridStep = 60.0
val tick = (grid.toDouble() * gridStep).toLong()
pointA = tick
val currentB = sharedScreenModel.loopingState.value.second
sharedScreenModel.updateLoopingState(grid, currentB)
}
actual fun setPointB() {
pointB = sequencer?.tickPosition ?: 0L
actual fun setPointB(grid: Int) {
val gridStep = 60.0
val tick = (((grid + 1).toDouble() * gridStep) - 1.0).toLong()
pointB = tick.coerceAtLeast(0L)
val currentA = sharedScreenModel.loopingState.value.first
sharedScreenModel.updateLoopingState(currentA, grid)
seekToGrid(currentA)
if (pointB > pointA && pointA != -1L) {
isLoopingAB = true
startABMonitor()
@ -942,6 +968,8 @@ actual class FMediaPlayer actual constructor(
actual fun clearLoop() {
isLoopingAB = false
abJob?.cancel()
sharedScreenModel.updateLoopingState(-1, -1)
pointA = -1L
pointB = -1L
}