fix(ci): il pattern gitignore 'scores/' escludeva anche i sorgenti
'scores/' senza slash iniziale ignorava ogni directory 'scores' a qualsiasi livello: mancavano dal repo il package com.notemusicali.scores, ScoresScreen e i 12 spartiti bundled. Ancorato a '/scores/' (solo la libreria alla radice) e aggiunti i file mancanti. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9387b50b67
commit
0add6de716
27 files changed
+890
-3
No files matched your search
@@ -0,0 +1,47 @@
|
||||
package com.notemusicali.scores
|
||||
|
||||
import notemusicali.shared.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||
|
||||
/**
|
||||
* Curated set of scores shipped inside the app as compose resources, so every
|
||||
* platform has content available even before the user picks a folder.
|
||||
*/
|
||||
object BundledScores {
|
||||
|
||||
private const val PREFIX = "bundled:"
|
||||
|
||||
// Resource file name (under files/scores/) to display name
|
||||
private val manifest = listOf(
|
||||
"happy_birthday.mxl" to "Happy Birthday",
|
||||
"ode_to_joy.mxl" to "Inno alla Gioia — Beethoven",
|
||||
"fur_elise.mxl" to "Per Elisa — Beethoven",
|
||||
"minuetto_bach.mxl" to "Minuetto in Sol — Bach",
|
||||
"preludio_bach.mxl" to "Preludio in Do — Bach",
|
||||
"canone_pachelbel.mxl" to "Canone in Re — Pachelbel",
|
||||
"gymnopedie_satie.mxl" to "Gymnopédie n.1 — Satie",
|
||||
"notturno_chopin.mxl" to "Notturno op.9 n.2 — Chopin",
|
||||
"lago_dei_cigni.mxl" to "Il Lago dei Cigni — Čajkovskij",
|
||||
"greensleeves.mxl" to "Greensleeves",
|
||||
"carol_of_the_bells.mxl" to "Carol of the Bells",
|
||||
"bella_ciao.mxl" to "Bella Ciao",
|
||||
)
|
||||
|
||||
val entries: List<ScoreEntry> = manifest.map { (file, displayName) ->
|
||||
ScoreEntry(name = displayName, path = PREFIX + file, isDirectory = false)
|
||||
}
|
||||
|
||||
fun isBundled(path: String): Boolean = path.startsWith(PREFIX)
|
||||
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
suspend fun read(path: String): ByteArray? {
|
||||
if (!isBundled(path)) return null
|
||||
val file = path.removePrefix(PREFIX)
|
||||
if (manifest.none { it.first == file }) return null
|
||||
return try {
|
||||
Res.readBytes("files/scores/$file")
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.notemusicali.scores
|
||||
|
||||
expect fun extractMusicXmlFromBytes(bytes: ByteArray): String?
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.notemusicali.scores
|
||||
|
||||
data class ScoreEntry(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val isDirectory: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.notemusicali.scores
|
||||
|
||||
expect object ScoreProvider {
|
||||
fun listScores(relativePath: String = ""): List<ScoreEntry>
|
||||
fun readScoreBytes(path: String): ByteArray?
|
||||
fun listFromUri(treeUri: String, documentId: String = ""): List<ScoreEntry>
|
||||
fun readFromUri(treeUri: String, documentId: String): ByteArray?
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.notemusicali.ui.scores
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.MusicNote
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.notemusicali.music.MusicXmlParser
|
||||
import com.notemusicali.music.NoteSequence
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import com.notemusicali.scores.BundledScores
|
||||
import com.notemusicali.scores.ScoreEntry
|
||||
import com.notemusicali.scores.ScoreProvider
|
||||
import com.notemusicali.scores.extractMusicXmlFromBytes
|
||||
import com.notemusicali.ui.components.BackTopBar
|
||||
import notemusicali.shared.generated.resources.Res
|
||||
import notemusicali.shared.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.notemusicali.ui.components.GradientCard
|
||||
import com.notemusicali.ui.components.rememberFolderPickerLauncher
|
||||
import com.notemusicali.ui.theme.CardGradients
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ScoresScreen(
|
||||
onBack: () -> Unit,
|
||||
onScoreSelected: (NoteSequence) -> Unit,
|
||||
) {
|
||||
var externalUri by remember {
|
||||
mutableStateOf(AppSettings.getString("scores_folder_uri")?.ifEmpty { null })
|
||||
}
|
||||
var externalName by remember {
|
||||
mutableStateOf(AppSettings.getString("scores_folder_name") ?: "")
|
||||
}
|
||||
var pathStack by remember { mutableStateOf(listOf<String>()) }
|
||||
val currentPath = pathStack.lastOrNull() ?: ""
|
||||
|
||||
val entries = remember(externalUri, currentPath) {
|
||||
if (externalUri != null) {
|
||||
ScoreProvider.listFromUri(externalUri!!, currentPath)
|
||||
} else {
|
||||
// App source: curated bundled scores first, then any file the user
|
||||
// dropped in the app's scores directory
|
||||
val bundled = if (currentPath.isEmpty()) BundledScores.entries else emptyList()
|
||||
bundled + ScoreProvider.listScores(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
var loadingPath by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val folderPicker = rememberFolderPickerLauncher { uri, name ->
|
||||
externalUri = uri
|
||||
externalName = name
|
||||
pathStack = emptyList()
|
||||
AppSettings.putString("scores_folder_uri", uri)
|
||||
AppSettings.putString("scores_folder_name", name)
|
||||
}
|
||||
|
||||
val scoresTitle = stringResource(Res.string.scores_title)
|
||||
val title = if (pathStack.isEmpty()) {
|
||||
if (externalUri != null) externalName.ifEmpty { scoresTitle } else scoresTitle
|
||||
} else {
|
||||
val last = pathStack.last()
|
||||
last.substringAfterLast("/").substringAfterLast(":").ifEmpty { last }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
|
||||
BackTopBar(
|
||||
title = title,
|
||||
onBack = {
|
||||
if (pathStack.isNotEmpty()) {
|
||||
pathStack = pathStack.dropLast(1)
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
// Source selector
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (externalUri != null) stringResource(Res.string.folder_named, externalName) else stringResource(Res.string.folder_app),
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.4f),
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
TextButton(onClick = { folderPicker() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Folder,
|
||||
contentDescription = stringResource(Res.string.change_folder),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.White.copy(alpha = 0.6f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(Res.string.change), fontSize = 12.sp, color = Color.White.copy(alpha = 0.6f))
|
||||
}
|
||||
if (externalUri != null) {
|
||||
TextButton(onClick = {
|
||||
externalUri = null
|
||||
externalName = ""
|
||||
pathStack = emptyList()
|
||||
AppSettings.putString("scores_folder_uri", "")
|
||||
AppSettings.putString("scores_folder_name", "")
|
||||
}) {
|
||||
Text("Reset", fontSize = 12.sp, color = Color.White.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.no_scores_found),
|
||||
fontSize = 16.sp,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
items(entries) { entry ->
|
||||
ScoreEntryCard(
|
||||
entry = entry,
|
||||
isLoading = loadingPath == entry.path,
|
||||
onClick = {
|
||||
if (entry.isDirectory) {
|
||||
pathStack = pathStack + entry.path
|
||||
} else if (loadingPath == null) {
|
||||
loadingPath = entry.path
|
||||
scope.launch {
|
||||
val sequence = withContext(Dispatchers.Default) {
|
||||
val bytes = when {
|
||||
BundledScores.isBundled(entry.path) -> BundledScores.read(entry.path)
|
||||
externalUri != null -> ScoreProvider.readFromUri(externalUri!!, entry.path)
|
||||
else -> ScoreProvider.readScoreBytes(entry.path)
|
||||
}
|
||||
bytes ?: return@withContext null
|
||||
val xml = extractMusicXmlFromBytes(bytes)
|
||||
?: return@withContext null
|
||||
val seq = MusicXmlParser.parse(xml, entry.name)
|
||||
if (seq.notes.isNotEmpty()) seq else null
|
||||
}
|
||||
loadingPath = null
|
||||
if (sequence != null) {
|
||||
onScoreSelected(sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreEntryCard(
|
||||
entry: ScoreEntry,
|
||||
isLoading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
GradientCard(
|
||||
gradient = if (entry.isDirectory) CardGradients.scores else CardGradients.scoreFile,
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (entry.isDirectory) Icons.Outlined.Folder else Icons.Outlined.MusicNote,
|
||||
contentDescription = if (entry.isDirectory) stringResource(Res.string.folder) else stringResource(Res.string.score_file),
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = entry.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (entry.isDirectory) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user