Implement playlist management and nav gest(Android): one-finger drag to next/prev stanza & two-finger next/prev playlist song - horizontal drag
This commit is contained in:
parent
bbcb59b7e6
commit
981e22e191
8 changed files with 2104 additions and 125 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
package mg.dot.feufaro
|
package mg.dot.feufaro
|
||||||
|
|
||||||
import SharedScreenModel
|
import SharedScreenModel
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
|
@ -8,14 +9,13 @@ import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Code
|
import androidx.compose.material.icons.filled.Code
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material.icons.filled.PlaylistRemove
|
||||||
import androidx.compose.material3.IconToggleButton
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -26,12 +26,14 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import cafe.adriel.voyager.core.screen.Screen
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
import cafe.adriel.voyager.koin.koinScreenModel
|
import cafe.adriel.voyager.koin.koinScreenModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import mg.dot.feufaro.data.GridTUOData
|
import mg.dot.feufaro.data.GridTUOData
|
||||||
import mg.dot.feufaro.solfa.ColorPrefixB
|
import mg.dot.feufaro.solfa.ColorPrefixB
|
||||||
|
|
@ -88,6 +90,16 @@ object ScreenSolfa : Screen {
|
||||||
}
|
}
|
||||||
|
|
||||||
val activeFilePath = sharedScreenModel.activeFilePath.value
|
val activeFilePath = sharedScreenModel.activeFilePath.value
|
||||||
|
val allowedStanzas by sharedScreenModel.allowedStanzas.collectAsState()
|
||||||
|
val playlistStanzas by sharedScreenModel.playlistStanzas.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(playlistStanzas, activeFilePath) {
|
||||||
|
if (playlistStanzas.isNotEmpty() && stanza !in playlistStanzas) {
|
||||||
|
sharedScreenModel.setStanza(playlistStanzas.first())
|
||||||
|
scrollState.scrollTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val drawerItems by sharedScreenModel.drawerItems.collectAsState()
|
val drawerItems by sharedScreenModel.drawerItems.collectAsState()
|
||||||
val altFiles = remember(activeFilePath, drawerItems) {
|
val altFiles = remember(activeFilePath, drawerItems) {
|
||||||
if (activeFilePath.isBlank()) {
|
if (activeFilePath.isBlank()) {
|
||||||
|
|
@ -149,37 +161,88 @@ object ScreenSolfa : Screen {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.pointerInput(stanza, nbStanzas) {
|
.pointerInput(stanza, allowedStanzas, activeFilePath) {
|
||||||
var totalDragX = 0f
|
awaitPointerEventScope {
|
||||||
detectHorizontalDragGestures(
|
while (true) {
|
||||||
onDragStart = { totalDragX = 0f },
|
val event = awaitPointerEvent()
|
||||||
onDragEnd = {
|
val pressed = event.changes.filter { it.pressed }
|
||||||
val currentStanza = stanza.toString().toIntOrNull() ?: 1
|
if (pressed.isEmpty()) continue
|
||||||
|
|
||||||
|
val isTwoFingers = pressed.size >= 2
|
||||||
|
val startX = if (isTwoFingers) (pressed[0].position.x + pressed[1].position.x) / 2f else pressed[0].position.x
|
||||||
|
val startY = if (isTwoFingers) (pressed[0].position.y + pressed[1].position.y) / 2f else pressed[0].position.y
|
||||||
|
|
||||||
|
var currentX = startX
|
||||||
|
var isHorizontalDrag = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTwoFingers) pressed.forEach { it.consume() }
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val moveEvent = awaitPointerEvent()
|
||||||
|
val movePressed = moveEvent.changes.filter { it.pressed }
|
||||||
|
|
||||||
|
if (isTwoFingers && movePressed.size < 2) break
|
||||||
|
if (!isTwoFingers && movePressed.size != 1) break
|
||||||
|
|
||||||
|
currentX = if (isTwoFingers) {
|
||||||
|
(movePressed[0].position.x + movePressed[1].position.x) / 2f
|
||||||
|
} else {
|
||||||
|
movePressed.first().position.x
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentY = if (isTwoFingers) {
|
||||||
|
(movePressed[0].position.y + movePressed[1].position.y) / 2f
|
||||||
|
} else {
|
||||||
|
movePressed.first().position.y
|
||||||
|
}
|
||||||
|
|
||||||
|
val diffX = kotlin.math.abs(currentX - startX)
|
||||||
|
val diffY = kotlin.math.abs(currentY - startY)
|
||||||
|
|
||||||
|
if (diffX > 15f || diffY > 15f) {
|
||||||
|
if (diffX > diffY) {
|
||||||
|
isHorizontalDrag = true
|
||||||
|
if (isTwoFingers) movePressed.forEach { it.consume() }
|
||||||
|
else movePressed.first().consume()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
|
||||||
|
if (isHorizontalDrag) {
|
||||||
|
val totalDragX = currentX - startX
|
||||||
val threshold = 70f
|
val threshold = 70f
|
||||||
|
|
||||||
if (totalDragX < -threshold) {
|
if (isTwoFingers) {
|
||||||
// Droite -> Gauche
|
val path = when {
|
||||||
if (currentStanza < nbStanzas) {
|
totalDragX < -threshold -> sharedScreenModel.getNextPlaylistItemPath()
|
||||||
sharedScreenModel.setStanza(currentStanza + 1)
|
totalDragX > threshold -> sharedScreenModel.getPrevPlaylistItemPath()
|
||||||
coroutineScope.launch {
|
else -> null
|
||||||
scrollState.animateScrollTo(0)
|
|
||||||
}
|
}
|
||||||
|
if (!path.isNullOrBlank()) {
|
||||||
|
sharedScreenModel.reset()
|
||||||
|
solfaScreenModel.loadFromFile(path)
|
||||||
}
|
}
|
||||||
} else if (totalDragX > threshold) {
|
} else {
|
||||||
// Gauche -> Droite
|
val currentStanza = stanza.toString().toIntOrNull() ?: 1
|
||||||
if (currentStanza > 1) {
|
val target = when {
|
||||||
sharedScreenModel.setStanza(currentStanza - 1)
|
totalDragX < -threshold -> sharedScreenModel.nextAllowedStanza(currentStanza)
|
||||||
|
totalDragX > threshold -> sharedScreenModel.prevAllowedStanza(currentStanza)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (target != null) {
|
||||||
|
sharedScreenModel.setStanza(target)
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
scrollState.animateScrollTo(0)
|
scrollState.animateScrollTo(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onHorizontalDrag = { change, dragAmount ->
|
|
||||||
change.consume()
|
|
||||||
totalDragX += dragAmount
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onGloballyPositioned { coordinates ->
|
.onGloballyPositioned { coordinates ->
|
||||||
viewportHeight = coordinates.size.height
|
viewportHeight = coordinates.size.height
|
||||||
|
|
@ -206,27 +269,133 @@ object ScreenSolfa : Screen {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
//.windowInsetsPadding(WindowInsets.safeDrawing)
|
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
) {
|
||||||
val measureString: String by sharedScreenModel.measure.collectAsState()
|
val measureString: String by sharedScreenModel.measure.collectAsState()
|
||||||
val songTitle: String by sharedScreenModel.songTitle.collectAsState()
|
val songTitle: String by sharedScreenModel.songTitle.collectAsState()
|
||||||
val songKey: String by sharedScreenModel.songKey.collectAsState()
|
val songKey: String by sharedScreenModel.songKey.collectAsState()
|
||||||
Text(text = songTitle, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
|
val favItems by sharedScreenModel.favItems.collectAsState()
|
||||||
|
val isFavorite = favItems.any { it.path == activeFilePath }
|
||||||
|
val isAndroid = remember { getPlatform().name.startsWith("Android") }
|
||||||
|
|
||||||
|
var playlistMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
LaunchedEffect(playlistMessage) {
|
||||||
|
if (playlistMessage != null) {
|
||||||
|
delay(2500)
|
||||||
|
playlistMessage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val selectedPlaylist by sharedScreenModel.selectedPlaylist.collectAsState()
|
||||||
|
val playlistsUi by sharedScreenModel.playlistsUi.collectAsState()
|
||||||
|
val isInPlaylist = remember(activeFilePath, selectedPlaylist, playlistsUi) {
|
||||||
|
sharedScreenModel.isSongInSelectedPlaylist(activeFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onFavorite = { sharedScreenModel.toFavorite(activeFilePath) }
|
||||||
|
val onTogglePlaylist: () -> Unit = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (isInPlaylist) {
|
||||||
|
sharedScreenModel.removeSongFromPlaylist(selectedPlaylist, activeFilePath)
|
||||||
|
playlistMessage = "Retiré de « $selectedPlaylist »"
|
||||||
|
} else {
|
||||||
|
val r = sharedScreenModel.addSongToPlaylist(
|
||||||
|
songTitle = songTitle,
|
||||||
|
path = activeFilePath,
|
||||||
|
)
|
||||||
|
playlistMessage = when {
|
||||||
|
r == null -> "Erreur : ajout impossible"
|
||||||
|
r.alreadyPresent -> "Déjà dans « ${r.playlistName} »"
|
||||||
|
else -> "Ajouté à « ${r.playlistName} »"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAndroid) {
|
||||||
|
// ANDROID
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, end = 8.dp, top = 8.dp)
|
||||||
|
) {
|
||||||
|
val sTitlewStz = if(playlistStanzas.isNotEmpty()) "$songTitle: ${playlistStanzas.joinToString(", ")}" else songTitle
|
||||||
|
Text(
|
||||||
|
text = sTitlewStz,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = songKey,
|
text = songKey,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f))
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f))
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
)
|
)
|
||||||
Text(text = measureString, style = MaterialTheme.typography.bodyLarge)
|
Text(text = measureString, style = MaterialTheme.typography.bodyLarge)
|
||||||
Text(text = "Stanza: $stanza", style = MaterialTheme.typography.bodyLarge)
|
Text(text = "Stanza: $stanza", style = MaterialTheme.typography.bodyLarge)
|
||||||
//ScreenTranspose.Content()
|
Spacer(Modifier.weight(1f))
|
||||||
|
SongActionButtons(
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
isInPlaylist = isInPlaylist,
|
||||||
|
enabled = activeFilePath.isNotBlank(),
|
||||||
|
onFavorite = onFavorite,
|
||||||
|
onTogglePlaylist = onTogglePlaylist,
|
||||||
|
modifier = Modifier.padding(end = 44.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// DESKTOP
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, end = 8.dp, top = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
val sTitlewStz = if(playlistStanzas.isNotEmpty()) "$songTitle: ${playlistStanzas.joinToString(", ")}" else songTitle
|
||||||
|
Text(
|
||||||
|
text = sTitlewStz,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = songKey,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f))
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
Text(text = measureString, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(text = "Stanza: $stanza", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
SongActionButtons(
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
isInPlaylist = isInPlaylist,
|
||||||
|
enabled = activeFilePath.isNotBlank(),
|
||||||
|
onFavorite = onFavorite,
|
||||||
|
onTogglePlaylist = onTogglePlaylist,
|
||||||
|
modifier = Modifier.padding(end = 44.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistMessage?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 2.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
LazyVerticalGridTUO(
|
LazyVerticalGridTUO(
|
||||||
gridTUOData,
|
gridTUOData,
|
||||||
|
|
@ -316,11 +485,28 @@ object ScreenSolfa : Screen {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
val nextLabel: String by sharedScreenModel.nextLabel.collectAsState()
|
val nextLabel: String by sharedScreenModel.nextLabel.collectAsState()
|
||||||
|
val prevLabel: String by sharedScreenModel.prevLabel.collectAsState()
|
||||||
|
|
||||||
if (nextLabel.isNotBlank()) {
|
|
||||||
MGButton(
|
MGButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
solfaScreenModel.loadNextInPlaylist()
|
val prevPath = sharedScreenModel.getPrevPlaylistItemPath()
|
||||||
|
if (!prevPath.isNullOrBlank()) {
|
||||||
|
sharedScreenModel.reset()
|
||||||
|
solfaScreenModel.loadFromFile(prevPath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.height(40.dp)
|
||||||
|
) {
|
||||||
|
Text(prevLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
MGButton(
|
||||||
|
onClick = {
|
||||||
|
val nextPath = sharedScreenModel.getNextPlaylistItemPath()
|
||||||
|
if (!nextPath.isNullOrBlank()) {
|
||||||
|
sharedScreenModel.reset()
|
||||||
|
solfaScreenModel.loadFromFile(nextPath)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.height(40.dp)
|
modifier = Modifier.height(40.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -329,7 +515,6 @@ object ScreenSolfa : Screen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
MyVerticalScrollbar(
|
MyVerticalScrollbar(
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
modifier = Modifier.align(Alignment.CenterEnd)
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
|
@ -338,6 +523,49 @@ object ScreenSolfa : Screen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SongActionButtons(
|
||||||
|
isFavorite: Boolean,
|
||||||
|
isInPlaylist: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onFavorite: () -> Unit,
|
||||||
|
onTogglePlaylist: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = onFavorite,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
border = BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
if (isFavorite) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||||
|
contentDescription = if (isFavorite) "Retirer des favoris" else "Ajouter aux favoris",
|
||||||
|
tint = if (isFavorite) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = onTogglePlaylist,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isInPlaylist) Icons.Default.PlaylistRemove else Icons.AutoMirrored.Filled.PlaylistAdd,
|
||||||
|
contentDescription = if (isInPlaylist) "Retirer de la playlist" else "Ajouter à la playlist",
|
||||||
|
tint = if (isInPlaylist) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(ObjectStreamException::class) // C'est une méthode de sérialisation Java, donc l'exception est nécessaire
|
@Throws(ObjectStreamException::class) // C'est une méthode de sérialisation Java, donc l'exception est nécessaire
|
||||||
private fun readResolve(): Any {
|
private fun readResolve(): Any {
|
||||||
return this // Toujours retourner l'instance unique de ce singleton
|
return this // Toujours retourner l'instance unique de ce singleton
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ data class DrawerItem(
|
||||||
val title: String,
|
val title: String,
|
||||||
val contentTitle: String,
|
val contentTitle: String,
|
||||||
val firstNotes: String = "",
|
val firstNotes: String = "",
|
||||||
val path: String
|
val path: String,
|
||||||
|
val totalStanzas: Int = 1
|
||||||
) {
|
) {
|
||||||
private data class TitleComponents(
|
private data class TitleComponents(
|
||||||
val prefix: String,
|
val prefix: String,
|
||||||
|
|
@ -32,7 +33,6 @@ data class DrawerItem(
|
||||||
val version = match.groupValues[3].ifEmpty { null }
|
val version = match.groupValues[3].ifEmpty { null }
|
||||||
return TitleComponents(prefix, number, version)
|
return TitleComponents(prefix, number, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun matchesQuery(query: String): Boolean {
|
fun matchesQuery(query: String): Boolean {
|
||||||
if (query.isBlank()) return true
|
if (query.isBlank()) return true
|
||||||
val cleanQuery = query.normalizeForSearch()
|
val cleanQuery = query.normalizeForSearch()
|
||||||
|
|
@ -47,9 +47,11 @@ data class DrawerItem(
|
||||||
val titleComponents = parseComponents(title)
|
val titleComponents = parseComponents(title)
|
||||||
if (queryComponents != null && titleComponents != null) {
|
if (queryComponents != null && titleComponents != null) {
|
||||||
if (queryComponents.prefix == titleComponents.prefix) {
|
if (queryComponents.prefix == titleComponents.prefix) {
|
||||||
|
if (queryComponents.number == titleComponents.number || titleComponents.number.startsWith(queryComponents.number)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val queryLetters = query.filter { it.isLetter() }.lowercase()
|
val queryLetters = query.filter { it.isLetter() }.lowercase()
|
||||||
val queryDigits = query.filter { it.isDigit() }
|
val queryDigits = query.filter { it.isDigit() }
|
||||||
|
|
@ -176,13 +178,15 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
|
||||||
resolveContent = resolveInternalContent
|
resolveContent = resolveInternalContent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val totalStanzas = extractTotalStanzas(content)
|
||||||
finalList.add(
|
finalList.add(
|
||||||
DrawerItem(
|
DrawerItem(
|
||||||
id = item.id,
|
id = item.id,
|
||||||
title = item.fileName.removeSuffix(".txt"),
|
title = item.fileName.removeSuffix(".txt"),
|
||||||
contentTitle = item.title,
|
contentTitle = item.title,
|
||||||
firstNotes = notes,
|
firstNotes = notes,
|
||||||
path = item.path
|
path = item.path,
|
||||||
|
totalStanzas = totalStanzas
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +211,8 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
|
||||||
title = file.nameWithoutExtension,
|
title = file.nameWithoutExtension,
|
||||||
contentTitle = extractTitle(file),
|
contentTitle = extractTitle(file),
|
||||||
firstNotes = extractFirstNotes(file, localBaseKeysWithAlt),
|
firstNotes = extractFirstNotes(file, localBaseKeysWithAlt),
|
||||||
path = file.absolutePath
|
path = file.absolutePath,
|
||||||
|
totalStanzas = extractTotalStanzas(file)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +224,28 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
|
||||||
return finalList
|
return finalList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val stanzaRegex = Regex("""^[EY](\d+):""")
|
||||||
|
|
||||||
|
private fun extractTotalStanzas(content: String): Int {
|
||||||
|
if (content.isBlank()) return 1
|
||||||
|
val numbers = content.lines().mapNotNull { line ->
|
||||||
|
stanzaRegex.find(line.trim())?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
return numbers.maxOrNull() ?: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTotalStanzas(file: File): Int {
|
||||||
|
return try {
|
||||||
|
val numbers = file.useLines { lines ->
|
||||||
|
lines.mapNotNull { line ->
|
||||||
|
stanzaRegex.find(line.trim())?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
}.toList()
|
||||||
|
}
|
||||||
|
numbers.maxOrNull() ?: 1
|
||||||
|
} catch (e: Exception) {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
private fun extractTitle(content: String): String? {
|
private fun extractTitle(content: String): String? {
|
||||||
val regex = Regex("""\|t:([^|]+)""")
|
val regex = Regex("""\|t:([^|]+)""")
|
||||||
return regex.find(content)?.groupValues?.get(1)?.trim()
|
return regex.find(content)?.groupValues?.get(1)?.trim()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package mg.dot.feufaro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlaylistsFile(
|
||||||
|
val playlist: List<Map<String, List<PlaylistSongEntry>>>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlaylistSongEntry(
|
||||||
|
val id: Int,
|
||||||
|
val chant: String,
|
||||||
|
val path: String,
|
||||||
|
val stanzas: List<Int>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PlaylistWithSongs(val name: String, val entries: List<PlaylistSongEntry>)
|
||||||
|
|
||||||
|
data class PlaylistSongUi(val entry: PlaylistSongEntry, val item: DrawerItem?)
|
||||||
|
|
||||||
|
data class PlaylistUi(val name: String, val songs: List<PlaylistSongUi>)
|
||||||
|
|
||||||
|
data class ActivePlaylistSong(val path: String, val stanzas: List<Int>)
|
||||||
|
|
@ -274,9 +274,15 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
|
||||||
val midiWriter = MidiWriterKotlin(fileRepository)
|
val midiWriter = MidiWriterKotlin(fileRepository)
|
||||||
midiWriter.process(pitches)
|
midiWriter.process(pitches)
|
||||||
midiWriter.save("whawyd3.mid")
|
midiWriter.save("whawyd3.mid")
|
||||||
|
val allowed = sharedScreenModel.allowedStanzas.value
|
||||||
|
val currentStz = sharedScreenModel.stanza.value
|
||||||
|
if (currentStz !in allowed && allowed.isNotEmpty()) {
|
||||||
|
sharedScreenModel.setStanza(allowed.first())
|
||||||
|
} else if (currentStz == 0) {
|
||||||
sharedScreenModel.setStanza(1)
|
sharedScreenModel.setStanza(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val prefs: Settings = provideSettings()
|
private val prefs: Settings = provideSettings()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import mg.dot.feufaro.data.ActivePlaylistSong
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
|
@ -73,6 +74,7 @@ fun MainScreenWithDrawer(
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
var showSettingsDialog by remember { mutableStateOf(false) }
|
var showSettingsDialog by remember { mutableStateOf(false) }
|
||||||
var showAboutDialog by remember { mutableStateOf(false) }
|
var showAboutDialog by remember { mutableStateOf(false) }
|
||||||
|
var showPlaylistDialog by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val items by sharedScreenModel.drawerItems.collectAsState()
|
val items by sharedScreenModel.drawerItems.collectAsState()
|
||||||
|
|
@ -233,6 +235,9 @@ fun MainScreenWithDrawer(
|
||||||
onSongSelected = { newSong ->
|
onSongSelected = { newSong ->
|
||||||
sharedScreenModel.loadNewSong("$midiFile")
|
sharedScreenModel.loadNewSong("$midiFile")
|
||||||
},
|
},
|
||||||
|
onPlaylistSreen = {
|
||||||
|
showPlaylistDialog = true
|
||||||
|
},
|
||||||
onSettingsCheck = {
|
onSettingsCheck = {
|
||||||
showSettingsDialog = true
|
showSettingsDialog = true
|
||||||
},
|
},
|
||||||
|
|
@ -287,7 +292,77 @@ fun MainScreenWithDrawer(
|
||||||
if (showAboutDialog) {
|
if (showAboutDialog) {
|
||||||
AboutDialog(onDismiss = { showAboutDialog = false })
|
AboutDialog(onDismiss = { showAboutDialog = false })
|
||||||
}
|
}
|
||||||
val playlistPaths by sharedScreenModel.playlistItems.collectAsState()
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
sharedScreenModel.loadPlaylists()
|
||||||
|
}
|
||||||
|
|
||||||
|
val playlistNames by sharedScreenModel.playlistNames.collectAsState()
|
||||||
|
val selectedPlaylist by sharedScreenModel.selectedPlaylist.collectAsState()
|
||||||
|
val currentPlaylistRawText by sharedScreenModel.currentPlaylistRawText.collectAsState()
|
||||||
|
|
||||||
|
if (showPlaylistDialog) {
|
||||||
|
PlaylistDialog(
|
||||||
|
playlistNames = playlistNames,
|
||||||
|
selectedPlaylist = selectedPlaylist,
|
||||||
|
onSelectPlaylist = { selected ->
|
||||||
|
scope.launch { sharedScreenModel.selectPlaylist(selected) }
|
||||||
|
},
|
||||||
|
onNewPlaylist = { newName ->
|
||||||
|
scope.launch { sharedScreenModel.createPlaylist(newName) }
|
||||||
|
},
|
||||||
|
onRenamePlaylist = { oldName, newName ->
|
||||||
|
scope.launch { sharedScreenModel.renamePlaylist(oldName, newName) }
|
||||||
|
},
|
||||||
|
onDeletePlaylist = { playlistName ->
|
||||||
|
scope.launch {
|
||||||
|
sharedScreenModel.deletePlaylist(playlistName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialRawText = currentPlaylistRawText,
|
||||||
|
drawerItems = items,
|
||||||
|
onAddFromLibrary = { },
|
||||||
|
onSave = { updatedRawText ->
|
||||||
|
scope.launch {
|
||||||
|
sharedScreenModel.savePlaylist(selectedPlaylist, updatedRawText)
|
||||||
|
showPlaylistDialog = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlay = { updatedRawText ->
|
||||||
|
scope.launch {
|
||||||
|
sharedScreenModel.savePlaylist(selectedPlaylist, updatedRawText)
|
||||||
|
sharedScreenModel.selectPlaylist(selectedPlaylist)
|
||||||
|
|
||||||
|
val songs = parseRawTextToEntries(updatedRawText).mapNotNull { entry ->
|
||||||
|
val song = findMatchingSong(entry.query, items) ?: return@mapNotNull null
|
||||||
|
ActivePlaylistSong(
|
||||||
|
path = song.path,
|
||||||
|
stanzas = parseVerses(entry.verseStr)?.sorted() ?: emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val firstPath = sharedScreenModel.startPlaylist(songs)
|
||||||
|
|
||||||
|
showPlaylistDialog = false
|
||||||
|
|
||||||
|
if (firstPath != null) {
|
||||||
|
sharedScreenModel.reset()
|
||||||
|
solfaScreenModel.loadFromFile(firstPath)
|
||||||
|
sharedScreenModel.loadNewSong(midiFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { showPlaylistDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val playlists by sharedScreenModel.playlistsUi.collectAsState()
|
||||||
|
val lastPlaylistPaths = remember(playlists) {
|
||||||
|
playlists.lastOrNull()
|
||||||
|
?.songs
|
||||||
|
?.flatMap { listOfNotNull(it.entry.path, it.item?.path) }
|
||||||
|
?.toSet()
|
||||||
|
?: emptySet()
|
||||||
|
}
|
||||||
val favoritePaths by sharedScreenModel.favItems.collectAsState()
|
val favoritePaths by sharedScreenModel.favItems.collectAsState()
|
||||||
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
@ -1170,7 +1245,7 @@ fun MainScreenWithDrawer(
|
||||||
}
|
}
|
||||||
itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item ->
|
itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item ->
|
||||||
val isFavorite = favoritePaths.contains(item)
|
val isFavorite = favoritePaths.contains(item)
|
||||||
val onPlaylist = playlistPaths.contains(item)
|
val onPlaylist = item.path in lastPlaylistPaths
|
||||||
val dismissState = rememberSwipeToDismissBoxState(
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
confirmValueChange = { dismissValue ->
|
confirmValueChange = { dismissValue ->
|
||||||
if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) {
|
if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) {
|
||||||
|
|
@ -1224,12 +1299,16 @@ fun MainScreenWithDrawer(
|
||||||
supportingContent = { Text(item.contentTitle, maxLines = 1) },
|
supportingContent = { Text(item.contentTitle, maxLines = 1) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { sharedScreenModel.toPlaylist(item.path) },
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.size(35.dp)
|
modifier = Modifier.size(35.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Default.PlaylistAdd,
|
imageVector = Icons.AutoMirrored.Default.PlaylistAdd,
|
||||||
contentDescription = "Btn add playlist",
|
contentDescription = if (onPlaylist) "Déjà dans la playlist" else "Btn add playlist",
|
||||||
tint = if (!onPlaylist) Color.LightGray else Color(
|
tint = if (!onPlaylist) Color.LightGray else Color(
|
||||||
0xFFFFD700
|
0xFFFFD700
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1064
composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PlaylistDialog.kt
Normal file
1064
composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PlaylistDialog.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.FeaturedPlayList
|
||||||
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
||||||
import androidx.compose.material.icons.automirrored.filled.NoteAdd
|
import androidx.compose.material.icons.automirrored.filled.NoteAdd
|
||||||
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
|
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
|
||||||
|
|
@ -25,12 +26,14 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import feufaro.composeapp.generated.resources.Res
|
import feufaro.composeapp.generated.resources.Res
|
||||||
import feufaro.composeapp.generated.resources.icon
|
import feufaro.composeapp.generated.resources.icon
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import mg.dot.feufaro.data.ActivePlaylistSong
|
||||||
import mg.dot.feufaro.data.DrawerItem
|
import mg.dot.feufaro.data.DrawerItem
|
||||||
import mg.dot.feufaro.getPlatform
|
import mg.dot.feufaro.getPlatform
|
||||||
import mg.dot.feufaro.solfa.Solfa
|
import mg.dot.feufaro.solfa.Solfa
|
||||||
|
|
@ -48,6 +51,7 @@ fun SimpleDrawerContent(
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
onSongSelected: (String) -> Unit,
|
onSongSelected: (String) -> Unit,
|
||||||
onSettingsCheck: () -> Unit,
|
onSettingsCheck: () -> Unit,
|
||||||
|
onPlaylistSreen: () -> Unit,
|
||||||
onAboutCheck: () -> Unit,
|
onAboutCheck: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var searchTxt by remember { mutableStateOf("") }
|
var searchTxt by remember { mutableStateOf("") }
|
||||||
|
|
@ -59,16 +63,40 @@ fun SimpleDrawerContent(
|
||||||
val internalList by sharedScreenModel.internalItems.collectAsState()
|
val internalList by sharedScreenModel.internalItems.collectAsState()
|
||||||
val externalList by sharedScreenModel.externalItems.collectAsState()
|
val externalList by sharedScreenModel.externalItems.collectAsState()
|
||||||
val playList by sharedScreenModel.playlistItems.collectAsState()
|
val playList by sharedScreenModel.playlistItems.collectAsState()
|
||||||
|
val playlists by sharedScreenModel.playlistsUi.collectAsState()
|
||||||
val favoList by sharedScreenModel.favItems.collectAsState()
|
val favoList by sharedScreenModel.favItems.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
var internalExpanded by remember { mutableStateOf(false) }
|
var internalExpanded by remember { mutableStateOf(false) }
|
||||||
var externalExpanded by remember { mutableStateOf(false) }
|
var externalExpanded by remember { mutableStateOf(false) }
|
||||||
var playListExpanded by remember { mutableStateOf(false) }
|
|
||||||
var favoriteExpanded by remember { mutableStateOf(false) }
|
var favoriteExpanded by remember { mutableStateOf(false) }
|
||||||
|
val playListExpanded by sharedScreenModel.playlistSectionExpanded.collectAsState()
|
||||||
|
val expandedPlaylists by sharedScreenModel.expandedPlaylistNames.collectAsState()
|
||||||
|
var playlistToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
val editMode by sharedScreenModel.modeEditor.collectAsState()
|
val editMode by sharedScreenModel.modeEditor.collectAsState()
|
||||||
val chordView by sharedScreenModel.harmonyView.collectAsState()
|
val chordView by sharedScreenModel.harmonyView.collectAsState()
|
||||||
|
playlistToDelete?.let { name ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { playlistToDelete = null },
|
||||||
|
title = {
|
||||||
|
Text("Supprimer la playlist \"$name\" ?", fontWeight = FontWeight.Bold)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
sharedScreenModel.deletePlaylist(name)
|
||||||
|
playlistToDelete = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Supprimer", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { playlistToDelete = null }) { Text("Annuler") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ModalDrawerSheet(
|
ModalDrawerSheet(
|
||||||
modifier = Modifier.width(300.dp)
|
modifier = Modifier.width(300.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -155,7 +183,7 @@ fun SimpleDrawerContent(
|
||||||
if (internalExpanded) {
|
if (internalExpanded) {
|
||||||
items(internalList) { item ->
|
items(internalList) { item ->
|
||||||
val isSelected = item.path == activePath
|
val isSelected = item.path == activePath
|
||||||
val onPlaylist = playList.contains(item)
|
val onPlaylist = playlists.any { pl -> pl.songs.any { it.entry.path == item.path } }
|
||||||
val isCurrentFavorite = favoList.contains(item)
|
val isCurrentFavorite = favoList.contains(item)
|
||||||
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
|
|
@ -166,7 +194,9 @@ fun SimpleDrawerContent(
|
||||||
badge = {
|
badge = {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
sharedScreenModel.toPlaylist(item.path)
|
scope.launch {
|
||||||
|
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -208,7 +238,7 @@ fun SimpleDrawerContent(
|
||||||
if (externalExpanded) {
|
if (externalExpanded) {
|
||||||
items(externalList) { item ->
|
items(externalList) { item ->
|
||||||
val isSelected = item.path == activePath
|
val isSelected = item.path == activePath
|
||||||
val onPlaylist = playList.contains(item)
|
val onPlaylist = playlists.any { pl -> pl.songs.any { it.entry.path == item.path } }
|
||||||
|
|
||||||
if (item.path != "") {
|
if (item.path != "") {
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
|
|
@ -219,7 +249,9 @@ fun SimpleDrawerContent(
|
||||||
badge = {
|
badge = {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
sharedScreenModel.toPlaylist(item.path)
|
scope.launch {
|
||||||
|
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -254,65 +286,71 @@ fun SimpleDrawerContent(
|
||||||
title = "Playlist",
|
title = "Playlist",
|
||||||
icon = Icons.AutoMirrored.Filled.PlaylistPlay,
|
icon = Icons.AutoMirrored.Filled.PlaylistPlay,
|
||||||
isExpanded = playListExpanded,
|
isExpanded = playListExpanded,
|
||||||
onToggle = { playListExpanded = !playListExpanded },
|
onToggle = { sharedScreenModel.togglePlaylistSection() },
|
||||||
count = playList.size
|
count = playlists.size
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playListExpanded) {
|
if (playListExpanded) {
|
||||||
itemsIndexed(playList) { index, item ->
|
playlists.forEach { pl ->
|
||||||
if (item.path.isNotBlank()) {
|
val isOpen = pl.name in expandedPlaylists
|
||||||
val isSelected = item.path == activePath
|
val activeSongs = pl.songs.mapNotNull { s ->
|
||||||
|
s.item?.let { ActivePlaylistSong(path = it.path, stanzas = s.entry.stanzas) }
|
||||||
|
}
|
||||||
|
|
||||||
NavigationDrawerItem(
|
item(key = "pl_${pl.name}") {
|
||||||
icon = {
|
DrawerPlaylistRow(
|
||||||
Text("♯", fontSize = 22.sp)
|
name = pl.name,
|
||||||
},
|
songCount = pl.songs.size,
|
||||||
label = {
|
expanded = isOpen,
|
||||||
DrawerItemLabel(item)
|
onToggle = {
|
||||||
},
|
sharedScreenModel.togglePlaylistExpanded(pl.name)
|
||||||
badge = {
|
scope.launch {
|
||||||
if (index > 0) {
|
sharedScreenModel.selectPlaylist(pl.name)
|
||||||
IconButton(
|
|
||||||
onClick = { sharedScreenModel.moveToTop(index) },
|
|
||||||
modifier = Modifier.size(30.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.VerticalAlignTop,
|
|
||||||
contentDescription = "Btn align playlist drawer",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { sharedScreenModel.toPlaylist(item.path) },
|
|
||||||
modifier = Modifier.size(30.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PlaylistRemove,
|
|
||||||
contentDescription = "Btn remove playlist drawer",
|
|
||||||
tint = if (isSelected) Color.LightGray else Color(0xFFFFD700)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selected = isSelected,
|
onDelete = { playlistToDelete = pl.name },
|
||||||
onClick = {
|
onPlay = {
|
||||||
|
scope.launch {
|
||||||
|
sharedScreenModel.selectPlaylist(pl.name)
|
||||||
|
val firstPath = sharedScreenModel.startPlaylist(activeSongs)
|
||||||
|
if (firstPath != null) {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
sharedScreenModel.reset()
|
sharedScreenModel.reset()
|
||||||
solfaScreenModel.loadFromFile(item.path)
|
solfaScreenModel.loadFromFile(firstPath)
|
||||||
onSongSelected(midi)
|
onSongSelected(midi)
|
||||||
},
|
}
|
||||||
shape = RoundedCornerShape(5.dp),
|
}
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
}
|
||||||
colors = NavigationDrawerItemDefaults.colors(
|
|
||||||
selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f),
|
|
||||||
selectedTextColor = MaterialTheme.colorScheme.onSecondary,
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
itemsIndexed(pl.songs, key = { index, _ -> "pls_${pl.name}_$index" }) { _, song ->
|
||||||
|
val di = song.item
|
||||||
|
DrawerPlaylistSongRow(
|
||||||
|
title = di?.title ?: song.entry.chant,
|
||||||
|
contentTitle = di?.contentTitle ?: "chant introuvable",
|
||||||
|
selected = di != null && di.path == activePath,
|
||||||
|
enabled = di != null,
|
||||||
|
stz = song.entry.stanzas,
|
||||||
|
onClick = {
|
||||||
|
if (di != null) {
|
||||||
|
scope.launch {
|
||||||
|
sharedScreenModel.selectPlaylist(pl.name)
|
||||||
|
sharedScreenModel.startPlaylist(activeSongs)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
sharedScreenModel.reset()
|
||||||
|
solfaScreenModel.loadFromFile(di.path)
|
||||||
|
onSongSelected(midi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -401,6 +439,19 @@ fun SimpleDrawerContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||||
|
tooltip = { PlainTooltip { Text("Gèrer la playlist") } },
|
||||||
|
state = rememberTooltipState()
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
onPlaylistSreen()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
}) {
|
||||||
|
Icon(Icons.AutoMirrored.Default.FeaturedPlayList, contentDescription = "Btn to manage playlist", tint = MaterialTheme.colorScheme.onSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||||
tooltip = { PlainTooltip { Text("Paramètres") } },
|
tooltip = { PlainTooltip { Text("Paramètres") } },
|
||||||
|
|
@ -538,3 +589,113 @@ fun CustomSwitchItem(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Composable
|
||||||
|
fun DrawerPlaylistRow(
|
||||||
|
name: String,
|
||||||
|
songCount: Int,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val rotation by animateFloatAsState(if (expanded) 180f else 0f)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(5.dp),
|
||||||
|
color = Color.Transparent,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.padding(start = 8.dp, end = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = if (expanded) "Replier $name" else "Déplier $name",
|
||||||
|
modifier = Modifier.rotate(rotation).size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (songCount <= 1) "$songCount partition" else "$songCount partitions",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onPlay,
|
||||||
|
enabled = songCount > 0,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Lancer la playlist $name",
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Supprimer la playlist $name",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DrawerPlaylistSongRow(
|
||||||
|
title: String,
|
||||||
|
contentTitle: String,
|
||||||
|
selected: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
stz: List<Int>,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val verses = if(stz.isEmpty()) "" else ": ${stz.joinToString(", ")}"
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = {
|
||||||
|
Text("♪", fontSize = 20.sp)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "$title$verses",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = contentTitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected = selected,
|
||||||
|
onClick = { if (enabled) onClick() },
|
||||||
|
shape = RoundedCornerShape(5.dp),
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 12.dp, top = 2.dp, bottom = 2.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f),
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.onSecondary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,26 +5,26 @@ import androidx.compose.runtime.setValue
|
||||||
import cafe.adriel.voyager.core.model.ScreenModel
|
import cafe.adriel.voyager.core.model.ScreenModel
|
||||||
import cafe.adriel.voyager.core.model.screenModelScope
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
import com.russhwolf.settings.Settings
|
import com.russhwolf.settings.Settings
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.serialization.json.Json
|
||||||
import mg.dot.feufaro.FileRepository
|
import mg.dot.feufaro.FileRepository
|
||||||
import mg.dot.feufaro.data.DrawerItem
|
import mg.dot.feufaro.data.*
|
||||||
import mg.dot.feufaro.data.GridTUOData
|
|
||||||
import mg.dot.feufaro.data.getCombinedList
|
|
||||||
import mg.dot.feufaro.midi.FMediaPlayer
|
import mg.dot.feufaro.midi.FMediaPlayer
|
||||||
import mg.dot.feufaro.provideSettings
|
import mg.dot.feufaro.provideSettings
|
||||||
import mg.dot.feufaro.solfa.TUOEditState
|
import mg.dot.feufaro.solfa.TUOEditState
|
||||||
import mg.dot.feufaro.solfa.TimeUnitObject
|
import mg.dot.feufaro.solfa.TimeUnitObject
|
||||||
import mg.dot.feufaro.solfa.getAllMarker
|
import mg.dot.feufaro.ui.findMatchingSong
|
||||||
|
import mg.dot.feufaro.ui.parseRawTextToEntries
|
||||||
|
import mg.dot.feufaro.ui.parseVerses
|
||||||
import mg.dot.feufaro.viewmodel.MidiMarkers
|
import mg.dot.feufaro.viewmodel.MidiMarkers
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class SharedScreenModel(private val fileRepository: FileRepository) : ScreenModel {
|
class SharedScreenModel(private val fileRepository: FileRepository) : ScreenModel {
|
||||||
private val _nextLabel = MutableStateFlow<String>("Next ...")
|
private val _nextLabel = MutableStateFlow<String>("Next ...")
|
||||||
val nextLabel: StateFlow<String> = _nextLabel.asStateFlow()
|
val nextLabel: StateFlow<String> = _nextLabel.asStateFlow()
|
||||||
|
private val _prevLabel = MutableStateFlow<String>("Prev ...")
|
||||||
|
val prevLabel: StateFlow<String> = _prevLabel.asStateFlow()
|
||||||
|
|
||||||
private val _loadFile = MutableStateFlow<String>("Load ...")
|
private val _loadFile = MutableStateFlow<String>("Load ...")
|
||||||
|
|
||||||
|
|
@ -118,7 +118,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
||||||
_silentDurationBefore.value = silent
|
_silentDurationBefore.value = silent
|
||||||
}
|
}
|
||||||
|
|
||||||
private val playlistFilename = "playlist.json"
|
private val playlistFilename = "playlists.json"
|
||||||
private val _playlistPaths = MutableStateFlow<List<String>>(emptyList())
|
private val _playlistPaths = MutableStateFlow<List<String>>(emptyList())
|
||||||
val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems ->
|
val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems ->
|
||||||
paths.mapNotNull { path -> allItems.find { it.path == path } }
|
paths.mapNotNull { path -> allItems.find { it.path == path } }
|
||||||
|
|
@ -130,6 +130,355 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
||||||
paths.mapNotNull { path -> allItems.find { it.path == path } }
|
paths.mapNotNull { path -> allItems.find { it.path == path } }
|
||||||
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
|
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
|
||||||
|
|
||||||
|
|
||||||
|
val playlistNames = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val selectedPlaylist = MutableStateFlow<String>("Dimanche Matin")
|
||||||
|
val currentPlaylistRawText = MutableStateFlow<String>("")
|
||||||
|
|
||||||
|
val playlistSectionExpanded = MutableStateFlow(false)
|
||||||
|
val expandedPlaylistNames = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
fun togglePlaylistSection() {
|
||||||
|
playlistSectionExpanded.value = !playlistSectionExpanded.value
|
||||||
|
}
|
||||||
|
fun togglePlaylistExpanded(name: String) {
|
||||||
|
val current = expandedPlaylistNames.value
|
||||||
|
expandedPlaylistNames.value = if (name in current) current - name else current + name
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _playlistsRaw = MutableStateFlow<List<PlaylistWithSongs>>(emptyList())
|
||||||
|
val playlistsUi: StateFlow<List<PlaylistUi>> = combine(_playlistsRaw, _drawerItems) { raw, all ->
|
||||||
|
raw.map { p ->
|
||||||
|
PlaylistUi(
|
||||||
|
name = p.name,
|
||||||
|
songs = p.entries.map { e ->
|
||||||
|
val item = all.find { it.path == e.path }
|
||||||
|
?: all.find { it.title.normalizeForSearch() == e.chant.normalizeForSearch() }
|
||||||
|
PlaylistSongUi(entry = e, item = item)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
|
||||||
|
|
||||||
|
private suspend fun refreshAllPlaylists() = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
if (!file.exists() || file.readText().isBlank()) {
|
||||||
|
_playlistsRaw.value = emptyList()
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val data = jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
_playlistsRaw.value = data.playlist.flatMap { map ->
|
||||||
|
map.map { (name, songs) -> PlaylistWithSongs(name, songs) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _activePlaylist = MutableStateFlow<List<ActivePlaylistSong>>(emptyList())
|
||||||
|
private val _activeFilePathFlow = MutableStateFlow("")
|
||||||
|
|
||||||
|
fun startPlaylist(songs: List<ActivePlaylistSong>): String? {
|
||||||
|
val valid = songs.filter { it.path.isNotBlank() }
|
||||||
|
_activePlaylist.value = valid
|
||||||
|
|
||||||
|
val first = valid.firstOrNull()
|
||||||
|
if (first != null) {
|
||||||
|
if (first.stanzas.isNotEmpty()) {
|
||||||
|
_stanza.value = first.stanzas.sorted().first()
|
||||||
|
} else {
|
||||||
|
_stanza.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return first?.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearActivePlaylist() {
|
||||||
|
_activePlaylist.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun samePath(a: String, b: String): Boolean =
|
||||||
|
a.isNotBlank() && b.isNotBlank() && (a == b || a.endsWith(b) || b.endsWith(a))
|
||||||
|
val playlistStanzas: StateFlow<List<Int>> =
|
||||||
|
combine(_activePlaylist, _activeFilePathFlow, _nbStanzas) { active, path, nb ->
|
||||||
|
val entry = active.find { samePath(it.path, path) }
|
||||||
|
entry?.stanzas?.filter { it in 1..nb }?.distinct()?.sorted().orEmpty()
|
||||||
|
}.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
val allowedStanzas: StateFlow<List<Int>> =
|
||||||
|
combine(playlistStanzas, _nbStanzas) { restricted, nb ->
|
||||||
|
if (restricted.isEmpty()) (1..nb).toList() else restricted
|
||||||
|
}.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
fun nextAllowedStanza(current: Int): Int? = allowedStanzas.value.firstOrNull { it > current }
|
||||||
|
fun prevAllowedStanza(current: Int): Int? = allowedStanzas.value.lastOrNull { it < current }
|
||||||
|
|
||||||
|
private val jsonFormat = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||||
|
suspend fun loadPlaylists() {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
|
||||||
|
if (!file.exists() || file.readText().isNotBlank() == false) {
|
||||||
|
val defaultData = PlaylistsFile(
|
||||||
|
playlist = listOf(
|
||||||
|
mapOf(
|
||||||
|
"Dimanche Matin" to listOf(
|
||||||
|
PlaylistSongEntry(1, "ews77", "assets://ews-77.txt", emptyList()),
|
||||||
|
PlaylistSongEntry(2, "ffpm77", "assets://ffpm-77.txt", listOf(1, 2, 3, 4)),
|
||||||
|
PlaylistSongEntry(2, "ffpm78", "assets://ffpm-78.txt", listOf(2))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
file.writeText(jsonFormat.encodeToString(defaultData))
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonString = file.readText()
|
||||||
|
if (jsonString.isNotBlank()) {
|
||||||
|
val playlistsFile = jsonFormat.decodeFromString<PlaylistsFile>(jsonString)
|
||||||
|
|
||||||
|
val names = playlistsFile.playlist.flatMap { it.keys }
|
||||||
|
playlistNames.value = names
|
||||||
|
refreshAllPlaylists()
|
||||||
|
|
||||||
|
if (names.isNotEmpty()) {
|
||||||
|
val currentSel = if (selectedPlaylist.value in names) selectedPlaylist.value else names.first()
|
||||||
|
selectPlaylist(currentSel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun selectPlaylist(name: String) {
|
||||||
|
selectedPlaylist.value = name
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
if (file.exists()) {
|
||||||
|
val playlistsFile = jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
val playlistMap = playlistsFile.playlist.find { it.containsKey(name) }
|
||||||
|
val songs = playlistMap?.get(name) ?: emptyList()
|
||||||
|
|
||||||
|
currentPlaylistRawText.value = songs.joinToString("; ") { song ->
|
||||||
|
val stanzasStr = if (song.stanzas.isNotEmpty()) song.stanzas.joinToString(",") else ""
|
||||||
|
if (stanzasStr.isNotBlank()) "${song.chant}:$stanzasStr" else song.chant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun createPlaylist(newName: String) {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
val currentData = if (file.exists() && file.readText().isNotBlank()) {
|
||||||
|
jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
} else {
|
||||||
|
PlaylistsFile(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentData.playlist.any { it.containsKey(newName) }) return
|
||||||
|
|
||||||
|
val updatedList = currentData.playlist + mapOf(newName to emptyList<PlaylistSongEntry>())
|
||||||
|
file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList)))
|
||||||
|
|
||||||
|
loadPlaylists()
|
||||||
|
selectPlaylist(newName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePlaylist(playlistName: String) {
|
||||||
|
screenModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
|
||||||
|
if (file.exists() && file.readText().isNotBlank()) {
|
||||||
|
val currentData = jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
val updatedList = currentData.playlist.filterNot { it.containsKey(playlistName) }
|
||||||
|
|
||||||
|
file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList)))
|
||||||
|
expandedPlaylistNames.value = expandedPlaylistNames.value - playlistName
|
||||||
|
|
||||||
|
if (updatedList.isEmpty()) {
|
||||||
|
playlistNames.value = emptyList()
|
||||||
|
selectedPlaylist.value = ""
|
||||||
|
currentPlaylistRawText.value = ""
|
||||||
|
refreshAllPlaylists()
|
||||||
|
} else {
|
||||||
|
loadPlaylists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun renamePlaylist(oldName: String, newName: String) {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
if (!file.exists() || file.readText().isBlank()) return
|
||||||
|
|
||||||
|
val currentData = jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
val updatedList = currentData.playlist.map { map ->
|
||||||
|
if (map.containsKey(oldName)) {
|
||||||
|
mapOf(newName to (map[oldName] ?: emptyList()))
|
||||||
|
} else {
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList)))
|
||||||
|
expandedPlaylistNames.value = expandedPlaylistNames.value
|
||||||
|
.map { if (it == oldName) newName else it }.toSet()
|
||||||
|
loadPlaylists()
|
||||||
|
selectPlaylist(newName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun savePlaylist(playlistName: String, rawText: String) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
val entries = parseRawTextToEntries(rawText)
|
||||||
|
val songEntries = entries.mapIndexed { index, entry ->
|
||||||
|
val stanzas = parseVerses(entry.verseStr)?.toList() ?: emptyList()
|
||||||
|
|
||||||
|
val matchedItem = findMatchingSong(entry.query, drawerItems.value)
|
||||||
|
|
||||||
|
PlaylistSongEntry(
|
||||||
|
id = index + 1,
|
||||||
|
chant = entry.query,
|
||||||
|
path = matchedItem?.path ?: "assets://${entry.query}.txt",
|
||||||
|
stanzas = stanzas
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
val currentData = if (file.exists() && file.readText().isNotBlank()) {
|
||||||
|
jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
} else {
|
||||||
|
PlaylistsFile(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedList = currentData.playlist.map { map ->
|
||||||
|
if (map.containsKey(playlistName)) {
|
||||||
|
mapOf(playlistName to songEntries)
|
||||||
|
} else {
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList)))
|
||||||
|
refreshAllPlaylists()
|
||||||
|
currentPlaylistRawText.value = rawText
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AddToPlaylistResult(val playlistName: String, val alreadyPresent: Boolean)
|
||||||
|
|
||||||
|
suspend fun addSongToPlaylist(
|
||||||
|
songTitle: String,
|
||||||
|
path: String,
|
||||||
|
stanzas: List<Int> = emptyList(),
|
||||||
|
): AddToPlaylistResult? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
val currentData = if (file.exists() && file.readText().isNotBlank()) {
|
||||||
|
jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
} else {
|
||||||
|
PlaylistsFile(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECTION : Utiliser la playlist actuellement sélectionnée au lieu de la dernière du fichier
|
||||||
|
val targetName = selectedPlaylist.value.ifBlank {
|
||||||
|
currentData.playlist.firstNotNullOfOrNull { it.keys.firstOrNull() } ?: "Ma playlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechercher l'index de la playlist ciblée dans la liste
|
||||||
|
val mapIndex = currentData.playlist.indexOfFirst { it.containsKey(targetName) }
|
||||||
|
val songs = if (mapIndex != -1) {
|
||||||
|
currentData.playlist[mapIndex][targetName] ?: emptyList()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si le chant est déjà présent dans cette playlist
|
||||||
|
if (songs.any { it.path == path }) {
|
||||||
|
return@withContext AddToPlaylistResult(targetName, alreadyPresent = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val chantKey = drawerItems.value.find { it.path == path }?.title ?: songTitle
|
||||||
|
|
||||||
|
val newSongs = songs + PlaylistSongEntry(
|
||||||
|
id = songs.size + 1,
|
||||||
|
chant = chantKey,
|
||||||
|
path = path,
|
||||||
|
stanzas = stanzas
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedMap = mapOf(targetName to newSongs)
|
||||||
|
val updatedList = if (mapIndex >= 0) {
|
||||||
|
currentData.playlist.toMutableList().also { it[mapIndex] = updatedMap }
|
||||||
|
} else {
|
||||||
|
currentData.playlist.toMutableList() + updatedMap
|
||||||
|
}
|
||||||
|
|
||||||
|
file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList)))
|
||||||
|
playlistNames.value = updatedList.flatMap { it.keys }
|
||||||
|
selectPlaylist(targetName)
|
||||||
|
|
||||||
|
refreshAllPlaylists()
|
||||||
|
AddToPlaylistResult(targetName, alreadyPresent = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSongInSelectedPlaylist(path: String): Boolean {
|
||||||
|
val currentName = selectedPlaylist.value
|
||||||
|
val pl = playlistsUi.value.find { it.name == currentName }
|
||||||
|
return pl?.songs?.any { it.entry.path == path || it.item?.path == path } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeSongFromPlaylist(playlistName: String, path: String) = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val folder = fileRepository.getAppPublicFolder()
|
||||||
|
val file = File(folder, playlistFilename)
|
||||||
|
if (!file.exists()) return@withContext
|
||||||
|
val currentData = jsonFormat.decodeFromString<PlaylistsFile>(file.readText())
|
||||||
|
val updatedList = currentData.playlist.map { map ->
|
||||||
|
if (map.containsKey(playlistName)) {
|
||||||
|
val songs = map[playlistName] ?: emptyList()
|
||||||
|
val filteredSongs = songs.filterNot { it.path == path }
|
||||||
|
mapOf(playlistName to filteredSongs)
|
||||||
|
} else {
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList)))
|
||||||
|
refreshAllPlaylists()
|
||||||
|
selectPlaylist(playlistName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
fun saveItToDisk(list: List<String>, name: String) {
|
fun saveItToDisk(list: List<String>, name: String) {
|
||||||
screenModelScope.launch {
|
screenModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
@ -347,6 +696,15 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
||||||
fun setFileContent(content: String?, path: String) {
|
fun setFileContent(content: String?, path: String) {
|
||||||
_fileContent.value = content
|
_fileContent.value = content
|
||||||
_activeFilePath.value = path
|
_activeFilePath.value = path
|
||||||
|
_activeFilePathFlow.value = path
|
||||||
|
|
||||||
|
val active = _activePlaylist.value
|
||||||
|
val entry = active.find { samePath(it.path, path) }
|
||||||
|
if (entry != null && entry.stanzas.isNotEmpty()) {
|
||||||
|
_stanza.value = entry.stanzas.sorted().first()
|
||||||
|
} else if (_stanza.value == 0) {
|
||||||
|
_stanza.value = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fun toggleQRCodeVisibility() {
|
fun toggleQRCodeVisibility() {
|
||||||
_isQRCodeVisible.value = !_isQRCodeVisible.value
|
_isQRCodeVisible.value = !_isQRCodeVisible.value
|
||||||
|
|
@ -427,6 +785,38 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
||||||
_mediaPlayer?.seekTo(micros / 1000)
|
_mediaPlayer?.seekTo(micros / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getPlaylistItemByOffset(offset: Int): DrawerItem? {
|
||||||
|
val active = _activePlaylist.value
|
||||||
|
val all = _drawerItems.value
|
||||||
|
val items = if (active.isNotEmpty()) {
|
||||||
|
active.mapNotNull { s -> all.find { it.path == s.path } }
|
||||||
|
} else {
|
||||||
|
playlistItems.value
|
||||||
|
}
|
||||||
|
val currentPath = _activeFilePathFlow.value
|
||||||
|
|
||||||
|
if (items.isEmpty()) return null
|
||||||
|
|
||||||
|
val currentIndex = items.indexOfFirst { item ->
|
||||||
|
item.path == currentPath || (currentPath.isNotBlank() && (item.path.endsWith(currentPath) || currentPath.endsWith(item.path)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (currentIndex != -1) {
|
||||||
|
val targetIndex = (currentIndex + offset).mod(items.size)
|
||||||
|
items[targetIndex]
|
||||||
|
} else {
|
||||||
|
items.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNextPlaylistItemPath(): String? {
|
||||||
|
return getPlaylistItemByOffset(1)?.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPrevPlaylistItemPath(): String? {
|
||||||
|
return getPlaylistItemByOffset(-1)?.path
|
||||||
|
}
|
||||||
|
|
||||||
private val _midiMarkersList = MutableStateFlow<List<MidiMarkers>>(emptyList())
|
private val _midiMarkersList = MutableStateFlow<List<MidiMarkers>>(emptyList())
|
||||||
val midiMarkersList: StateFlow<List<MidiMarkers>> = _midiMarkersList
|
val midiMarkersList: StateFlow<List<MidiMarkers>> = _midiMarkersList
|
||||||
private val _activeIndex = MutableStateFlow(-1)
|
private val _activeIndex = MutableStateFlow(-1)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue