From 60c11b7ac8bdd84caf8814deb2d5e8224e52dfd6 Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Mon, 7 Sep 2026 14:09:40 +0300 Subject: [PATCH] Implement smart B placementfor A-B looping based measure - windows --- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 4 +- .../mg/dot/feufaro/solfa/TimeUnitObject.kt | 52 ++++++-- .../mg/dot/feufaro/ui/MidiControlPanel.kt | 4 +- .../kotlin/mg/dot/feufaro/ui/Settings.kt | 119 ++++++++++++------ .../feufaro/viewmodel/SharedScreenModel.kt | 33 ++++- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 52 ++++++-- 6 files changed, 205 insertions(+), 59 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt index 55a05ec..6bafbdf 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt index 86db629..c39278d 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt @@ -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) @@ -1084,7 +1107,21 @@ fun LazyVerticalGridTUO( selectedIndex = globalIndex showEditDialog = true } 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 -> @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt index b66d9a1..3c88dfc 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt index f9f6b64..13ca9b0 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt @@ -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,46 +699,94 @@ fun Settings( color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth() ) { - Row( - 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) - ) + Column { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - if(!isAndroid) { - val countInStat = if (hasCountIn) "Désactiver" else "Activer" - Text( - text = countInStat, - style = MaterialTheme.typography.bodyMedium + 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( + 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, - 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) + } + 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) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index da056de..39d52b9 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -444,6 +444,36 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _activeIndex.value = clamped } } + + private val _loopingState = MutableStateFlow(Pair(-1, -1)) + val loopingState: StateFlow> = _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 = _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 = "" diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt index 0c9e5e4..4fb08a6 100644 --- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -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(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) { 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 }