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
|
||||
|
||||
import SharedScreenModel
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
|
|
@ -8,14 +9,13 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.Code
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlaylistRemove
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.text.font.FontStyle
|
||||
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.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import mg.dot.feufaro.data.GridTUOData
|
||||
import mg.dot.feufaro.solfa.ColorPrefixB
|
||||
|
|
@ -88,6 +90,16 @@ object ScreenSolfa : Screen {
|
|||
}
|
||||
|
||||
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 altFiles = remember(activeFilePath, drawerItems) {
|
||||
if (activeFilePath.isBlank()) {
|
||||
|
|
@ -149,37 +161,88 @@ object ScreenSolfa : Screen {
|
|||
}
|
||||
)
|
||||
}
|
||||
.pointerInput(stanza, nbStanzas) {
|
||||
var totalDragX = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { totalDragX = 0f },
|
||||
onDragEnd = {
|
||||
val currentStanza = stanza.toString().toIntOrNull() ?: 1
|
||||
val threshold = 70f
|
||||
.pointerInput(stanza, allowedStanzas, activeFilePath) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val pressed = event.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) continue
|
||||
|
||||
if (totalDragX < -threshold) {
|
||||
// Droite -> Gauche
|
||||
if (currentStanza < nbStanzas) {
|
||||
sharedScreenModel.setStanza(currentStanza + 1)
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollTo(0)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (totalDragX > threshold) {
|
||||
// Gauche -> Droite
|
||||
if (currentStanza > 1) {
|
||||
sharedScreenModel.setStanza(currentStanza - 1)
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollTo(0)
|
||||
} catch (e: Exception) {}
|
||||
|
||||
if (isHorizontalDrag) {
|
||||
val totalDragX = currentX - startX
|
||||
val threshold = 70f
|
||||
|
||||
if (isTwoFingers) {
|
||||
val path = when {
|
||||
totalDragX < -threshold -> sharedScreenModel.getNextPlaylistItemPath()
|
||||
totalDragX > threshold -> sharedScreenModel.getPrevPlaylistItemPath()
|
||||
else -> null
|
||||
}
|
||||
if (!path.isNullOrBlank()) {
|
||||
sharedScreenModel.reset()
|
||||
solfaScreenModel.loadFromFile(path)
|
||||
}
|
||||
} else {
|
||||
val currentStanza = stanza.toString().toIntOrNull() ?: 1
|
||||
val target = when {
|
||||
totalDragX < -threshold -> sharedScreenModel.nextAllowedStanza(currentStanza)
|
||||
totalDragX > threshold -> sharedScreenModel.prevAllowedStanza(currentStanza)
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
sharedScreenModel.setStanza(target)
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
totalDragX += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.onGloballyPositioned { coordinates ->
|
||||
viewportHeight = coordinates.size.height
|
||||
|
|
@ -206,28 +269,134 @@ object ScreenSolfa : Screen {
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.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 songTitle: String by sharedScreenModel.songTitle.collectAsState()
|
||||
val songKey: String by sharedScreenModel.songKey.collectAsState()
|
||||
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 measureString: String by sharedScreenModel.measure.collectAsState()
|
||||
val songTitle: String by sharedScreenModel.songTitle.collectAsState()
|
||||
val songKey: String by sharedScreenModel.songKey.collectAsState()
|
||||
Text(text = songTitle, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
|
||||
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 = 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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))
|
||||
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)
|
||||
//ScreenTranspose.Content()
|
||||
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(
|
||||
gridTUOData,
|
||||
gridWidthPx = gridWidthPx,
|
||||
|
|
@ -316,16 +485,32 @@ object ScreenSolfa : Screen {
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val nextLabel: String by sharedScreenModel.nextLabel.collectAsState()
|
||||
val prevLabel: String by sharedScreenModel.prevLabel.collectAsState()
|
||||
|
||||
if (nextLabel.isNotBlank()) {
|
||||
MGButton(
|
||||
onClick = {
|
||||
solfaScreenModel.loadNextInPlaylist()
|
||||
},
|
||||
modifier = Modifier.height(40.dp)
|
||||
) {
|
||||
Text(nextLabel)
|
||||
}
|
||||
MGButton(
|
||||
onClick = {
|
||||
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)
|
||||
) {
|
||||
Text(nextLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
private fun readResolve(): Any {
|
||||
return this // Toujours retourner l'instance unique de ce singleton
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ data class DrawerItem(
|
|||
val title: String,
|
||||
val contentTitle: String,
|
||||
val firstNotes: String = "",
|
||||
val path: String
|
||||
val path: String,
|
||||
val totalStanzas: Int = 1
|
||||
) {
|
||||
private data class TitleComponents(
|
||||
val prefix: String,
|
||||
|
|
@ -32,7 +33,6 @@ data class DrawerItem(
|
|||
val version = match.groupValues[3].ifEmpty { null }
|
||||
return TitleComponents(prefix, number, version)
|
||||
}
|
||||
|
||||
fun matchesQuery(query: String): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
val cleanQuery = query.normalizeForSearch()
|
||||
|
|
@ -47,7 +47,9 @@ data class DrawerItem(
|
|||
val titleComponents = parseComponents(title)
|
||||
if (queryComponents != null && titleComponents != null) {
|
||||
if (queryComponents.prefix == titleComponents.prefix) {
|
||||
return true
|
||||
if (queryComponents.number == titleComponents.number || titleComponents.number.startsWith(queryComponents.number)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,13 +178,15 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
|
|||
resolveContent = resolveInternalContent
|
||||
)
|
||||
|
||||
val totalStanzas = extractTotalStanzas(content)
|
||||
finalList.add(
|
||||
DrawerItem(
|
||||
id = item.id,
|
||||
title = item.fileName.removeSuffix(".txt"),
|
||||
contentTitle = item.title,
|
||||
firstNotes = notes,
|
||||
path = item.path
|
||||
path = item.path,
|
||||
totalStanzas = totalStanzas
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -207,7 +211,8 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
|
|||
title = file.nameWithoutExtension,
|
||||
contentTitle = extractTitle(file),
|
||||
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
|
||||
}
|
||||
|
||||
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? {
|
||||
val regex = Regex("""\|t:([^|]+)""")
|
||||
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,7 +274,13 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
|
|||
val midiWriter = MidiWriterKotlin(fileRepository)
|
||||
midiWriter.process(pitches)
|
||||
midiWriter.save("whawyd3.mid")
|
||||
sharedScreenModel.setStanza(1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import mg.dot.feufaro.data.ActivePlaylistSong
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
|
|
@ -73,6 +74,7 @@ fun MainScreenWithDrawer(
|
|||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
var showSettingsDialog by remember { mutableStateOf(false) }
|
||||
var showAboutDialog by remember { mutableStateOf(false) }
|
||||
var showPlaylistDialog by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val items by sharedScreenModel.drawerItems.collectAsState()
|
||||
|
|
@ -233,6 +235,9 @@ fun MainScreenWithDrawer(
|
|||
onSongSelected = { newSong ->
|
||||
sharedScreenModel.loadNewSong("$midiFile")
|
||||
},
|
||||
onPlaylistSreen = {
|
||||
showPlaylistDialog = true
|
||||
},
|
||||
onSettingsCheck = {
|
||||
showSettingsDialog = true
|
||||
},
|
||||
|
|
@ -287,7 +292,77 @@ fun MainScreenWithDrawer(
|
|||
if (showAboutDialog) {
|
||||
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 keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
|
@ -1170,7 +1245,7 @@ fun MainScreenWithDrawer(
|
|||
}
|
||||
itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item ->
|
||||
val isFavorite = favoritePaths.contains(item)
|
||||
val onPlaylist = playlistPaths.contains(item)
|
||||
val onPlaylist = item.path in lastPlaylistPaths
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { dismissValue ->
|
||||
if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) {
|
||||
|
|
@ -1224,12 +1299,16 @@ fun MainScreenWithDrawer(
|
|||
supportingContent = { Text(item.contentTitle, maxLines = 1) },
|
||||
trailingContent = {
|
||||
IconButton(
|
||||
onClick = { sharedScreenModel.toPlaylist(item.path) },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(35.dp)
|
||||
) {
|
||||
Icon(
|
||||
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(
|
||||
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.shape.RoundedCornerShape
|
||||
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.NoteAdd
|
||||
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.text.font.FontStyle
|
||||
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.sp
|
||||
import feufaro.composeapp.generated.resources.Res
|
||||
import feufaro.composeapp.generated.resources.icon
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import mg.dot.feufaro.data.ActivePlaylistSong
|
||||
import mg.dot.feufaro.data.DrawerItem
|
||||
import mg.dot.feufaro.getPlatform
|
||||
import mg.dot.feufaro.solfa.Solfa
|
||||
|
|
@ -48,6 +51,7 @@ fun SimpleDrawerContent(
|
|||
scope: CoroutineScope,
|
||||
onSongSelected: (String) -> Unit,
|
||||
onSettingsCheck: () -> Unit,
|
||||
onPlaylistSreen: () -> Unit,
|
||||
onAboutCheck: () -> Unit,
|
||||
) {
|
||||
var searchTxt by remember { mutableStateOf("") }
|
||||
|
|
@ -59,16 +63,40 @@ fun SimpleDrawerContent(
|
|||
val internalList by sharedScreenModel.internalItems.collectAsState()
|
||||
val externalList by sharedScreenModel.externalItems.collectAsState()
|
||||
val playList by sharedScreenModel.playlistItems.collectAsState()
|
||||
val playlists by sharedScreenModel.playlistsUi.collectAsState()
|
||||
val favoList by sharedScreenModel.favItems.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var internalExpanded by remember { mutableStateOf(false) }
|
||||
var externalExpanded by remember { mutableStateOf(false) }
|
||||
var playListExpanded 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 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(
|
||||
modifier = Modifier.width(300.dp)
|
||||
) {
|
||||
|
|
@ -155,7 +183,7 @@ fun SimpleDrawerContent(
|
|||
if (internalExpanded) {
|
||||
items(internalList) { item ->
|
||||
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)
|
||||
|
||||
NavigationDrawerItem(
|
||||
|
|
@ -166,7 +194,9 @@ fun SimpleDrawerContent(
|
|||
badge = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
sharedScreenModel.toPlaylist(item.path)
|
||||
scope.launch {
|
||||
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
|
|
@ -208,7 +238,7 @@ fun SimpleDrawerContent(
|
|||
if (externalExpanded) {
|
||||
items(externalList) { item ->
|
||||
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 != "") {
|
||||
NavigationDrawerItem(
|
||||
|
|
@ -219,7 +249,9 @@ fun SimpleDrawerContent(
|
|||
badge = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
sharedScreenModel.toPlaylist(item.path)
|
||||
scope.launch {
|
||||
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
|
|
@ -254,63 +286,69 @@ fun SimpleDrawerContent(
|
|||
title = "Playlist",
|
||||
icon = Icons.AutoMirrored.Filled.PlaylistPlay,
|
||||
isExpanded = playListExpanded,
|
||||
onToggle = { playListExpanded = !playListExpanded },
|
||||
count = playList.size
|
||||
onToggle = { sharedScreenModel.togglePlaylistSection() },
|
||||
count = playlists.size
|
||||
)
|
||||
}
|
||||
|
||||
if (playListExpanded) {
|
||||
itemsIndexed(playList) { index, item ->
|
||||
if (item.path.isNotBlank()) {
|
||||
val isSelected = item.path == activePath
|
||||
playlists.forEach { pl ->
|
||||
val isOpen = pl.name in expandedPlaylists
|
||||
val activeSongs = pl.songs.mapNotNull { s ->
|
||||
s.item?.let { ActivePlaylistSong(path = it.path, stanzas = s.entry.stanzas) }
|
||||
}
|
||||
|
||||
NavigationDrawerItem(
|
||||
icon = {
|
||||
Text("♯", fontSize = 22.sp)
|
||||
},
|
||||
label = {
|
||||
DrawerItemLabel(item)
|
||||
},
|
||||
badge = {
|
||||
if (index > 0) {
|
||||
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
|
||||
)
|
||||
item(key = "pl_${pl.name}") {
|
||||
DrawerPlaylistRow(
|
||||
name = pl.name,
|
||||
songCount = pl.songs.size,
|
||||
expanded = isOpen,
|
||||
onToggle = {
|
||||
sharedScreenModel.togglePlaylistExpanded(pl.name)
|
||||
scope.launch {
|
||||
sharedScreenModel.selectPlaylist(pl.name)
|
||||
}
|
||||
},
|
||||
onDelete = { playlistToDelete = pl.name },
|
||||
onPlay = {
|
||||
scope.launch {
|
||||
sharedScreenModel.selectPlaylist(pl.name)
|
||||
val firstPath = sharedScreenModel.startPlaylist(activeSongs)
|
||||
if (firstPath != null) {
|
||||
scope.launch { drawerState.close() }
|
||||
sharedScreenModel.reset()
|
||||
solfaScreenModel.loadFromFile(firstPath)
|
||||
onSongSelected(midi)
|
||||
}
|
||||
}
|
||||
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,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
sharedScreenModel.reset()
|
||||
solfaScreenModel.loadFromFile(item.path)
|
||||
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(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
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.screenModelScope
|
||||
import com.russhwolf.settings.Settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import mg.dot.feufaro.FileRepository
|
||||
import mg.dot.feufaro.data.DrawerItem
|
||||
import mg.dot.feufaro.data.GridTUOData
|
||||
import mg.dot.feufaro.data.getCombinedList
|
||||
import mg.dot.feufaro.data.*
|
||||
import mg.dot.feufaro.midi.FMediaPlayer
|
||||
import mg.dot.feufaro.provideSettings
|
||||
import mg.dot.feufaro.solfa.TUOEditState
|
||||
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 java.io.File
|
||||
|
||||
class SharedScreenModel(private val fileRepository: FileRepository) : ScreenModel {
|
||||
private val _nextLabel = MutableStateFlow<String>("Next ...")
|
||||
val nextLabel: StateFlow<String> = _nextLabel.asStateFlow()
|
||||
private val _prevLabel = MutableStateFlow<String>("Prev ...")
|
||||
val prevLabel: StateFlow<String> = _prevLabel.asStateFlow()
|
||||
|
||||
private val _loadFile = MutableStateFlow<String>("Load ...")
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
|||
_silentDurationBefore.value = silent
|
||||
}
|
||||
|
||||
private val playlistFilename = "playlist.json"
|
||||
private val playlistFilename = "playlists.json"
|
||||
private val _playlistPaths = MutableStateFlow<List<String>>(emptyList())
|
||||
val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems ->
|
||||
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 } }
|
||||
}.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) {
|
||||
screenModelScope.launch {
|
||||
try {
|
||||
|
|
@ -347,6 +696,15 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
|||
fun setFileContent(content: String?, path: String) {
|
||||
_fileContent.value = content
|
||||
_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() {
|
||||
_isQRCodeVisible.value = !_isQRCodeVisible.value
|
||||
|
|
@ -427,6 +785,38 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
|
|||
_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())
|
||||
val midiMarkersList: StateFlow<List<MidiMarkers>> = _midiMarkersList
|
||||
private val _activeIndex = MutableStateFlow(-1)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue