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 seekTo(position: Long)
fun seekToGrid(gridIndex: Int) fun seekToGrid(gridIndex: Int)
fun setVolume(level: Float) fun setVolume(level: Float)
fun setPointA() fun setPointA(grid: Int)
fun setPointB() fun setPointB(grid: Int)
fun clearLoop() fun clearLoop()
fun setTempo(bpm: Float) fun setTempo(bpm: Float)
fun getCurrentBPM(): Float fun getCurrentBPM(): Float

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.window.Popup
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
import cafe.adriel.voyager.navigator.internal.BackHandler import cafe.adriel.voyager.navigator.internal.BackHandler
import com.russhwolf.settings.Settings
import feufaro.composeapp.generated.resources.Emmentaler import feufaro.composeapp.generated.resources.Emmentaler
import feufaro.composeapp.generated.resources.PTSerif_Bold import feufaro.composeapp.generated.resources.PTSerif_Bold
import feufaro.composeapp.generated.resources.PT_Serif_Bold_Italic 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.data.GridTUOData
import mg.dot.feufaro.getPathValidator import mg.dot.feufaro.getPathValidator
import mg.dot.feufaro.getPlatform import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.provideSettings
import mg.dot.feufaro.ui.rememberFileSaveLauncher import mg.dot.feufaro.ui.rememberFileSaveLauncher
import mg.dot.feufaro.viewmodel.MidiMarkers import mg.dot.feufaro.viewmodel.MidiMarkers
import mg.dot.feufaro.viewmodel.SolfaScreenModel import mg.dot.feufaro.viewmodel.SolfaScreenModel
@ -283,18 +285,19 @@ fun TimeUnitComposable(
isCurrentBlock: Boolean = false, isCurrentBlock: Boolean = false,
parentFocusRequester: FocusRequester? = null, parentFocusRequester: FocusRequester? = null,
transpositionInterval: Int, 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 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 currentDensity = LocalDensity.current
val colorUnderline = MaterialTheme.colorScheme.onSecondary val colorUnderline = MaterialTheme.colorScheme.onSecondary
val animatedColor = if (gridActive) { val animatedColor = when {
animateColorAsState( gridActive -> animateColorAsState(
targetValue = MaterialTheme.colorScheme.secondary.copy(alpha = 1f), targetValue = MaterialTheme.colorScheme.secondary.copy(alpha = 1f),
animationSpec = tween(durationMillis = 100) animationSpec = tween(durationMillis = 100)
).value ).value
} else { isInABrange -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.25f)
col else -> col
} }
val focusRequesters = remember { List(4) { FocusRequester() } } val focusRequesters = remember { List(4) { FocusRequester() } }
@ -636,6 +639,15 @@ fun LazyVerticalGridTUO(
val platform = getPlatform() val platform = getPlatform()
val isAndroid = platform.name.startsWith("Android") 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -999,6 +1011,17 @@ fun LazyVerticalGridTUO(
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
measureTUOs.forEachIndexed { indexInMeasure, oneTUO -> measureTUOs.forEachIndexed { indexInMeasure, oneTUO ->
val globalIndex = (measureIndex * gridColumnCount) + indexInMeasure 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 isActive = (globalIndex == activeRowIndex)
val isSelectedByKeyboard = (globalIndex == selectedGridIndex) val isSelectedByKeyboard = (globalIndex == selectedGridIndex)
@ -1084,7 +1107,21 @@ fun LazyVerticalGridTUO(
selectedIndex = globalIndex selectedIndex = globalIndex
showEditDialog = true showEditDialog = true
} else { } else {
sharedScreenModel.seekToGrid(globalIndex) 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 -> onDoubleTap = { offset ->
@ -1108,7 +1145,8 @@ fun LazyVerticalGridTUO(
isCurrentBlock = isSelectedByKeyboard && editMode, isCurrentBlock = isSelectedByKeyboard && editMode,
parentFocusRequester = focusRequester, parentFocusRequester = focusRequester,
transpositionInterval = currentInterval, transpositionInterval = currentInterval,
hairpinRange = hairpinByEndBlock[oneTUO.numBlock] hairpinRange = hairpinByEndBlock[oneTUO.numBlock],
isInABrange = isInABrange
) )
if (showContextualMenu && selectedIndex == globalIndex) { if (showContextualMenu && selectedIndex == globalIndex) {

View file

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

View file

@ -51,6 +51,7 @@ fun Settings(
) { ) {
val prefs: Settings = provideSettings() val prefs: Settings = provideSettings()
var hasCountIn by remember { mutableStateOf(prefs.getBoolean("isCountIn", true)) } 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 isDynamicEnabled by remember { mutableStateOf(Dynamic.isGloballyEnabled) }
var refreshTrigger by remember { mutableStateOf(0) } var refreshTrigger by remember { mutableStateOf(0) }
val isFullScreenEnabled by sharedScreenModel.isFullScreen.collectAsState() val isFullScreenEnabled by sharedScreenModel.isFullScreen.collectAsState()
@ -698,46 +699,94 @@ fun Settings(
color = MaterialTheme.colorScheme.surfaceContainerLow, color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row( Column {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Décompte avant la lecture ",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(10.dp) .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
if(!isAndroid) { Text(
val countInStat = if (hasCountIn) "Désactiver" else "Activer" text = "Décompte avant la lecture ",
Text( style = MaterialTheme.typography.bodyMedium,
text = countInStat, fontWeight = FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (!isAndroid) {
val countInStat = if (hasCountIn) "Désactiver" else "Activer"
Text(
text = countInStat,
style = MaterialTheme.typography.bodyMedium
)
}
Switch(
checked = hasCountIn,
onCheckedChange = { enabled ->
hasCountIn = enabled
prefs.putBoolean("isCountIn", 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)
) )
} }
Switch( }
checked = hasCountIn, HorizontalDivider(
onCheckedChange = { enabled -> color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
hasCountIn = enabled modifier = Modifier.padding(vertical = 4.dp)
prefs.putBoolean("isCountIn", enabled) )
}, Row(
colors = SwitchDefaults.colors( modifier = Modifier
checkedThumbColor = MaterialTheme.colorScheme.onPrimary, .fillMaxWidth()
checkedTrackColor = MaterialTheme.colorScheme.primary, .padding(horizontal = 16.dp, vertical = 8.dp),
uncheckedThumbColor = MaterialTheme.colorScheme.outline, horizontalArrangement = Arrangement.SpaceBetween,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest, verticalAlignment = Alignment.CenterVertically
uncheckedBorderColor = MaterialTheme.colorScheme.outline ) {
), Text(
modifier = Modifier.size(45.dp) 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 _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) private val _gridCount = MutableStateFlow(0)
val gridCount: StateFlow<Int> = _gridCount.asStateFlow() val gridCount: StateFlow<Int> = _gridCount.asStateFlow()
@ -681,7 +711,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_isPos.value = false _isPos.value = false
player.play() player.play()
player.setVolume(_volumeLevel.value) player.setVolume(_volumeLevel.value)
if(currentPos.value == 0f) { if (currentPos.value == 0f && _loopingState.value.first == -1) {
player.seekTo(0) player.seekTo(0)
} }
} }
@ -749,6 +779,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
setTranspositionInterval(0) setTranspositionInterval(0)
setTranspToC(0) setTranspToC(0)
setSilentDurBefr(0) setSilentDurBefr(0)
updateLoopingState(0, 0)
_midiMarkersList.value = emptyList() _midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList() _tuoTimestamps.value = emptyList()
_searchTitle.value = "" _searchTitle.value = ""

View file

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