diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt index d9da7840b..2995b4d78 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt @@ -171,7 +171,7 @@ class AiHandler @Inject constructor( AiSystemPromptType.TAGGING -> 0.4f AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f AiSystemPromptType.PERSONA -> 0.85f - AiSystemPromptType.GENERAL -> 0.7f + AiSystemPromptType.GENERAL, AiSystemPromptType.LYRICS -> 0.7f } } else temperature } else params.temperature @@ -182,7 +182,7 @@ class AiHandler @Inject constructor( val basePersona = getBasePersona(userProvider) val combinedSystemPrompt = promptEngine.buildPrompt(basePersona, type, context) - val hash = (userProvider.name + combinedSystemPrompt + prompt).sha256() + val hash = (type.name + userProvider.name + combinedSystemPrompt + prompt).sha256() cacheDao.getCache(hash)?.let { cached -> val age = System.currentTimeMillis() - cached.timestamp diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt index 9edf9e404..a103af1f0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt @@ -11,6 +11,7 @@ enum class AiSystemPromptType { MOOD_ANALYSIS, PERSONA, DAILY_MIX, + LYRICS, GENERAL } @@ -182,6 +183,16 @@ class AiSystemPromptEngine @Inject constructor() { $dailyMixPersonaPrompt """.trimIndent() + AiSystemPromptType.LYRICS -> """ + Song lyrics translator — you translate lyrics between languages while preserving structure. + + - Preserve ALL timestamps and line structure exactly. + - Output each original line followed by its translation on the next line. + - Never add explanations, labels, or formatting beyond the requested format. + - If the source is already in the target language, respond with: ALREADY_IN_TARGET_LANGUAGE + + """.trimIndent() + AiSystemPromptType.GENERAL -> """ PixelPlayer Assistant — a knowledgeable music companion. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt index 229f1d314..31c2a0d39 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt @@ -3,18 +3,23 @@ package com.theveloper.pixelplay.data.ai.provider /** * Enum representing available AI providers */ -enum class AiProvider(val displayName: String, val requiresApiKey: Boolean, val hasConfigurableUrl: Boolean = false) { - GEMINI("Google Gemini", requiresApiKey = true), - DEEPSEEK("DeepSeek", requiresApiKey = true), - GROQ("Groq", requiresApiKey = true), - MISTRAL("Mistral", requiresApiKey = true), - NVIDIA("NVIDIA NIM", requiresApiKey = true), - KIMI("Kimi (Moonshot)", requiresApiKey = true), - GLM("Zhipu GLM", requiresApiKey = true), - OPENAI("OpenAI", requiresApiKey = true), - OPENROUTER("OpenRouter", requiresApiKey = true), - OLLAMA("Ollama", requiresApiKey = true), - CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true); +enum class AiProvider( + val displayName: String, + val requiresApiKey: Boolean, + val hasConfigurableUrl: Boolean = false, + val description: String = "" +) { + GEMINI("Google Gemini", requiresApiKey = true, description = "Google's flagship AI models (Gemini 1.5/2.0)"), + DEEPSEEK("DeepSeek", requiresApiKey = true, description = "Open-source reasoning models via DeepSeek API"), + GROQ("Groq", requiresApiKey = true, description = "Ultra-fast inference with Groq LPU hardware"), + MISTRAL("Mistral", requiresApiKey = true, description = "Mistral AI's efficient and powerful models"), + NVIDIA("NVIDIA NIM", requiresApiKey = true, description = "NVIDIA's optimized inference microservices"), + KIMI("Kimi (Moonshot)", requiresApiKey = true, description = "Moonshot AI's long-context Kimi models"), + GLM("Zhipu GLM", requiresApiKey = true, description = "Zhipu AI's bilingual GLM series"), + OPENAI("OpenAI", requiresApiKey = true, description = "GPT-4o, GPT-4, and other OpenAI models"), + OPENROUTER("OpenRouter", requiresApiKey = true, description = "Unified access to 200+ models via OpenRouter"), + OLLAMA("Ollama", requiresApiKey = true, description = "Local models via Ollama (requires running server)"), + CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true, description = "Any OpenAI-compatible API endpoint"); companion object { fun fromString(value: String): AiProvider { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt index 36d9081b9..d7bcb635f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt @@ -77,12 +77,6 @@ interface MusicRepository { storageFilter: com.theveloper.pixelplay.data.model.StorageFilter = com.theveloper.pixelplay.data.model.StorageFilter.ALL ): Flow - /** - * Returns the count of songs in the library. - * @return Flow emitting the current song count. - */ - fun getSongCountFlow(): Flow - /** * Returns the count of cloud songs in the library. */ diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt index 5f026af8d..11a16bd1e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt @@ -310,10 +310,6 @@ class MusicRepositoryImpl @Inject constructor( return songRepository.getFavoriteSongCountFlow(storageFilter) } - override fun getSongCountFlow(): Flow { - return musicDao.getSongCount().distinctUntilChanged() - } - override fun getCloudSongCountFlow(): Flow { return musicDao.getCloudSongCount().distinctUntilChanged() } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt index 7d827896f..5a3d1c789 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.rounded.LibraryMusic import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.QueueMusic +import androidx.compose.material.icons.rounded.Tune import androidx.compose.ui.graphics.vector.ImageVector import com.theveloper.pixelplay.R @@ -62,6 +63,12 @@ enum class SettingsCategory( subtitleRes = R.string.settings_category_ai_subtitle, iconRes = R.drawable.gemini_ai ), + GENERATION_PARAMETERS( + id = "generation_parameters", + titleRes = R.string.settings_category_generation_parameters_title, + subtitleRes = R.string.settings_category_generation_parameters_subtitle, + icon = Icons.Rounded.Tune + ), BACKUP_RESTORE( id = "backup_restore", titleRes = R.string.settings_category_backup_title, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt index 750f992a7..97933c5dd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt @@ -179,6 +179,7 @@ private fun formatPromptType(type: String): String { "MOOD_ANALYSIS" -> "Analysis" "PERSONA" -> "Persona" "DAILY_MIX" -> "Daily Mix" + "LYRICS" -> "Lyrics" "GENERAL" -> "General" else -> type.lowercase().replaceFirstChar { it.uppercase() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 5d37d029e..3c9a6590a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -1002,11 +1002,11 @@ fun LibraryScreen( }.collectAsStateWithLifecycle(initialValue = LibraryScreenPlayerProjection()) val isLibraryContentEmpty by remember(playerViewModel) { combine( - playerViewModel.songCountFlow, + playerViewModel.allSongsFlow, playerViewModel.albumsFlow, playerViewModel.artistsFlow - ) { songCount, albums, artists -> - songCount == 0 && albums.isEmpty() && artists.isEmpty() + ) { songs, albums, artists -> + songs.isEmpty() && albums.isEmpty() && artists.isEmpty() }.distinctUntilChanged() }.collectAsStateWithLifecycle(initialValue = true) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index bb97e988b..1e3e68a7c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -923,12 +923,12 @@ fun SettingsCategoryScreen( // AI Provider Selection SettingsSubsection(title = stringResource(R.string.settings_ai_provider_section)) { - ThemeSelectorItem( + SearchableProviderSelector( label = stringResource(R.string.settings_ai_provider_title), description = stringResource(R.string.settings_ai_provider_subtitle), - options = com.theveloper.pixelplay.data.ai.provider.AiProvider.entries.associate { it.name to it.displayName }, - selectedKey = aiProvider, - onSelectionChanged = { settingsViewModel.onAiProviderChange(it) }, + providers = com.theveloper.pixelplay.data.ai.provider.AiProvider.entries, + selectedProvider = aiProvider, + onProviderSelected = { settingsViewModel.onAiProviderChange(it) }, leadingIcon = { Icon(Icons.Rounded.Science, null, tint = MaterialTheme.colorScheme.secondary) } ) SwitchSettingItem( @@ -1039,6 +1039,107 @@ fun SettingsCategoryScreen( } } + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSubsection(title = stringResource(R.string.settings_ai_usage_report_section)) { + val recentAiUsage by settingsViewModel.recentAiUsage.collectAsStateWithLifecycle() + val totalPromptTokens by settingsViewModel.totalPromptTokens.collectAsStateWithLifecycle() + val totalOutputTokens by settingsViewModel.totalOutputTokens.collectAsStateWithLifecycle() + val totalThoughtTokens by settingsViewModel.totalThoughtTokens.collectAsStateWithLifecycle() + + val totalTokens = totalPromptTokens + totalOutputTokens + totalThoughtTokens + val totalTokStr = String.format(Locale.US, "%,d", totalTokens) + val promptTokStr = String.format(Locale.US, "%,d", totalPromptTokens) + val outputTokStr = String.format(Locale.US, "%,d", totalOutputTokens) + val thoughtTokStr = String.format(Locale.US, "%,d", totalThoughtTokens) + + ActionSettingsItem( + title = stringResource(R.string.settings_total_consumption_title), + subtitle = stringResource( + R.string.settings_ai_usage_tokens_subtitle, + totalTokStr, + promptTokStr, + outputTokStr, + thoughtTokStr + ), + icon = { + Icon( + painter = painterResource(R.drawable.rounded_monitoring_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + }, + primaryActionLabel = stringResource(R.string.settings_ai_clear_logs), + onPrimaryAction = { settingsViewModel.clearAiUsageData() } + ) + + if (recentAiUsage.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + var expanded by remember { mutableStateOf(false) } + val rotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + color = Color.Transparent + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.rounded_monitoring_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.settings_ai_activity_log_title, recentAiUsage.size), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = GoogleSansRounded), + color = MaterialTheme.colorScheme.onSurface + ) + } + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.settings_ai_hide_logs) else stringResource(R.string.settings_ai_show_logs), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp) + ) { + val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) + val groupedUsage = recentAiUsage.groupBy { + dateFormat.format(Date(it.timestamp)) + } + + groupedUsage.forEach { (date, items) -> + AiUsageDateHeader(date = date) + items.forEach { usage -> + AiUsageLogItem(usage = usage) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } + } + SettingsCategory.GENERATION_PARAMETERS -> { // Prompt Behavior Section SettingsSubsection( title = stringResource(R.string.settings_prompt_behavior_section), @@ -1142,6 +1243,8 @@ fun SettingsCategoryScreen( ) } + Spacer(modifier = Modifier.height(16.dp)) + // Song Data Configuration Section SettingsSubsection(title = "Song Data Configuration") { val aiSampleSize by settingsViewModel.aiSampleSize.collectAsStateWithLifecycle() @@ -1187,107 +1290,6 @@ fun SettingsCategoryScreen( } ) } - - Spacer(modifier = Modifier.height(16.dp)) - - SettingsSubsection(title = stringResource(R.string.settings_ai_usage_report_section)) { - val recentAiUsage by settingsViewModel.recentAiUsage.collectAsStateWithLifecycle() - val totalPromptTokens by settingsViewModel.totalPromptTokens.collectAsStateWithLifecycle() - val totalOutputTokens by settingsViewModel.totalOutputTokens.collectAsStateWithLifecycle() - val totalThoughtTokens by settingsViewModel.totalThoughtTokens.collectAsStateWithLifecycle() - - val totalTokens = totalPromptTokens + totalOutputTokens + totalThoughtTokens - val totalTokStr = String.format(Locale.US, "%,d", totalTokens) - val promptTokStr = String.format(Locale.US, "%,d", totalPromptTokens) - val outputTokStr = String.format(Locale.US, "%,d", totalOutputTokens) - val thoughtTokStr = String.format(Locale.US, "%,d", totalThoughtTokens) - - ActionSettingsItem( - title = stringResource(R.string.settings_total_consumption_title), - subtitle = stringResource( - R.string.settings_ai_usage_tokens_subtitle, - totalTokStr, - promptTokStr, - outputTokStr, - thoughtTokStr - ), - icon = { - Icon( - painter = painterResource(R.drawable.rounded_monitoring_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary - ) - }, - primaryActionLabel = stringResource(R.string.settings_ai_clear_logs), - onPrimaryAction = { settingsViewModel.clearAiUsageData() } - ) - - if (recentAiUsage.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - var expanded by remember { mutableStateOf(false) } - val rotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f) - - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - color = Color.Transparent - ) { - Row( - modifier = Modifier.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.rounded_monitoring_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.settings_ai_activity_log_title, recentAiUsage.size), - style = MaterialTheme.typography.titleMedium.copy(fontFamily = GoogleSansRounded), - color = MaterialTheme.colorScheme.onSurface - ) - } - Icon( - imageVector = Icons.Rounded.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.settings_ai_hide_logs) else stringResource(R.string.settings_ai_show_logs), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.rotate(rotation) - ) - } - } - - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 8.dp) - ) { - @Suppress("NonObservableLocale") - val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) - val groupedUsage = recentAiUsage.groupBy { - dateFormat.format(Date(it.timestamp)) - } - - groupedUsage.forEach { (date, items) -> - AiUsageDateHeader(date = date) - items.forEach { usage -> - AiUsageLogItem(usage = usage) - } - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - } - } } SettingsCategory.BACKUP_RESTORE -> { if (!uiState.backupInfoDismissed) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt index bc5dbb329..0f9ca6572 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.theveloper.pixelplay.presentation.screens import androidx.compose.animation.AnimatedContent @@ -551,6 +553,184 @@ fun SearchableModelSelector( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchableProviderSelector( + label: String, + description: String, + providers: List, + selectedProvider: String, + onProviderSelected: (String) -> Unit, + leadingIcon: @Composable () -> Unit +) { + var showSheet by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + val selectedDisplayName = providers.find { it.name == selectedProvider }?.displayName + ?: selectedProvider + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { showSheet = true } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + contentAlignment = Alignment.Center + ) { leadingIcon() } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(10.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = CircleShape, + modifier = Modifier.align(Alignment.Start) + ) { + Text( + text = selectedDisplayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + } + } + + if (showSheet) { + ModalBottomSheet( + onDismissRequest = { + showSheet = false + searchQuery = "" + }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + Text( + text = label, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + fontWeight = FontWeight.Bold + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search providers...") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + val filteredProviders = remember(providers, searchQuery) { + if (searchQuery.isBlank()) providers + else providers.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.displayName.contains(searchQuery, ignoreCase = true) || + it.description.contains(searchQuery, ignoreCase = true) + } + } + + Text( + text = "${filteredProviders.size} provider${if (filteredProviders.size != 1) "s" else ""} available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) + ) + + LazyColumn( + modifier = Modifier + .padding(horizontal = 16.dp) + .heightIn(max = 400.dp) + ) { + items(filteredProviders, key = { it.name }) { provider -> + val isSelected = provider.name == selectedProvider + Surface( + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + onProviderSelected(provider.name) + showSheet = false + searchQuery = "" + } + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = provider.displayName, + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + ) + Text( + text = provider.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + } +} + @Composable fun SliderSettingsItem( label: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt index 292d3448e..2acbf769a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt @@ -484,6 +484,7 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair SettingsCategory.PLAYBACK -> Color(0xFF633B48) to Color(0xFFFFD8EC) SettingsCategory.BEHAVIOR -> Color(0xFF3E4C63) to Color(0xFFD7E3FF) SettingsCategory.AI_INTEGRATION -> Color(0xFF004F58) to Color(0xFF88FAFF) + SettingsCategory.GENERATION_PARAMETERS -> Color(0xFF4A256B) to Color(0xFFE8B0FF) SettingsCategory.BACKUP_RESTORE -> Color(0xFF3B4869) to Color(0xFFD9E2FF) SettingsCategory.DEVELOPER -> Color(0xFF324F34) to Color(0xFFCBEFD0) SettingsCategory.EQUALIZER -> Color(0xFF6E4E13) to Color(0xFFFFDEAC) @@ -498,6 +499,7 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair SettingsCategory.PLAYBACK -> Color(0xFFFFD8EC) to Color(0xFF631B4B) SettingsCategory.BEHAVIOR -> Color(0xFFD7E3FF) to Color(0xFF253347) SettingsCategory.AI_INTEGRATION -> Color(0xFFCCE8EA) to Color(0xFF004F58) + SettingsCategory.GENERATION_PARAMETERS -> Color(0xFFE8B0FF) to Color(0xFF3B1A53) SettingsCategory.BACKUP_RESTORE -> Color(0xFFD9E2FF) to Color(0xFF27304E) SettingsCategory.DEVELOPER -> Color(0xFFCBEFD0) to Color(0xFF042106) SettingsCategory.EQUALIZER -> Color(0xFFFFDEAC) to Color(0xFF281900) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt index 8e125a235..20595b9be 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt @@ -295,7 +295,7 @@ class AiStateHolder @Inject constructor( } else { "Translate these song lyrics into $targetLanguage." } - val prompt = """ + val xmlPrompt = """ $taskPrefix @@ -317,8 +317,8 @@ $lyricsText """.trimIndent() val response = aiHandler.generateContent( - prompt = prompt, - type = AiSystemPromptType.GENERAL, + prompt = xmlPrompt, + type = AiSystemPromptType.LYRICS, temperature = 0.1f ) Result.success(response) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt index 22fb7d311..b3417eb27 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first @@ -238,6 +239,12 @@ class LibraryStateHolder @Inject constructor( private var albumsJob: Job? = null private var artistsJob: Job? = null private var foldersJob: Job? = null + private var categoriesJob: Job? = null + + private var songsFirstBatch = CompletableDeferred() + private var albumsFirstBatch = CompletableDeferred() + private var artistsFirstBatch = CompletableDeferred() + @Volatile private var needsReloadAfterTrim: Boolean = false @@ -251,29 +258,43 @@ class LibraryStateHolder @Inject constructor( return } + // Cancel any still-active jobs before resetting gates and launching new ones, + // otherwise old collectors will complete the new deferreds prematurely. + songsJob?.cancel() + albumsJob?.cancel() + artistsJob?.cancel() + foldersJob?.cancel() + categoriesJob?.cancel() + + // Reset one-shot completion gates so that after trimMemory + restart + // the sequential loading order is preserved (await() will suspend again). + songsFirstBatch = CompletableDeferred() + albumsFirstBatch = CompletableDeferred() + artistsFirstBatch = CompletableDeferred() + Log.d("LibraryStateHolder", "startObservingLibraryData called.") needsReloadAfterTrim = false songsJob = scope?.launch { _isLoadingLibrary.value = true musicRepository.getAudioFiles().conflate().collect { songs -> - // Process heavy list conversions on Default dispatcher to avoid blocking UI + songsFirstBatch.complete(Unit) val immutableSongs = withContext(Dispatchers.Default) { songs.toImmutableList() } val songsMap = withContext(Dispatchers.Default) { songs.associateBy { it.id } } _allSongs.value = immutableSongs _allSongsById.value = songsMap - // When the repository emits a new list (triggered by directory changes), - // we update our state and re-apply current sorting. - // Apply sort to the new data sortSongs(_currentSongSortOption.value, persist = false) _isLoadingLibrary.value = false } } + // Albums, artists, and folders load sequentially after songs' first emission + // to avoid I/O contention on slower storage. Uses CompletableDeferred instead of + // Job.join() because the underlying flows never complete (they're infinite reactive streams). albumsJob = scope?.launch { - _isLoadingCategories.value = true + songsFirstBatch.await() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) kotlinx.coroutines.flow.combine( effectiveStorageFilter, @@ -283,29 +304,30 @@ class LibraryStateHolder @Inject constructor( }.flatMapLatest { (filter, minTracks) -> musicRepository.getAlbums(filter, minTracks) }.conflate().collect { albums -> + albumsFirstBatch.complete(Unit) val sortedAlbums = withContext(Dispatchers.Default) { sortAlbumsList(albums, _currentAlbumSortOption.value).toImmutableList() } _albums.value = sortedAlbums - _isLoadingCategories.value = false } } artistsJob = scope?.launch { - _isLoadingCategories.value = true + albumsFirstBatch.await() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getArtists(filter) }.conflate().collect { artists -> + artistsFirstBatch.complete(Unit) val sortedArtists = withContext(Dispatchers.Default) { sortArtistsList(artists, _currentArtistSortOption.value).toImmutableList() } _artists.value = sortedArtists - _isLoadingCategories.value = false } } foldersJob = scope?.launch { + artistsFirstBatch.await() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getMusicFolders(effectiveFoldersStorageFilter(filter)) @@ -316,6 +338,14 @@ class LibraryStateHolder @Inject constructor( _musicFolders.value = sortedFolders } } + + // Loading categories state: true after songs first batch, false after artists first batch. + categoriesJob = scope?.launch { + songsFirstBatch.await() + _isLoadingCategories.value = true + artistsFirstBatch.await() + _isLoadingCategories.value = false + } } // Deprecated imperative loaders - redirected to observer start @@ -581,17 +611,20 @@ class LibraryStateHolder @Inject constructor( songsJob?.isActive == true || albumsJob?.isActive == true || artistsJob?.isActive == true || - foldersJob?.isActive == true + foldersJob?.isActive == true || + categoriesJob?.isActive == true if (!hasLoadedData && !hasActiveCollectors) return songsJob?.cancel() albumsJob?.cancel() artistsJob?.cancel() foldersJob?.cancel() + categoriesJob?.cancel() songsJob = null albumsJob = null artistsJob = null foldersJob = null + categoriesJob = null _allSongs.value = persistentListOf() _allSongsById.value = emptyMap() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 4ddadbede..8fff62bb4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -536,14 +536,6 @@ class PlayerViewModel @Inject constructor( initialValue = false ) - val hasGeminiApiKey: StateFlow = aiPreferencesRepository.geminiApiKey - .map { it.isNotBlank() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false - ) - val fullPlayerLoadingTweaks: StateFlow = userPreferencesRepository.fullPlayerLoadingTweaksFlow .stateIn( scope = viewModelScope, @@ -1140,13 +1132,6 @@ class PlayerViewModel @Inject constructor( initialValue = persistentListOf() ) - val songCountFlow: StateFlow = musicRepository.getSongCountFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = 0 - ) - val hasCloudSongsFlow: StateFlow = musicRepository.getCloudSongCountFlow() .map { it > 0 } .distinctUntilChanged() @@ -1527,15 +1512,16 @@ class PlayerViewModel @Inject constructor( - viewModelScope.launch { - userPreferencesRepository.migrateTabOrder() - } - + // Sort defaults must be set immediately so LibraryStateHolder.initialize() + // reads the correct sort options rather than pre-migration values. viewModelScope.launch { userPreferencesRepository.ensureLibrarySortDefaults() } + // Deferred: legacy migrations run after init to avoid blocking first render viewModelScope.launch { + kotlinx.coroutines.delay(2000) + userPreferencesRepository.migrateTabOrder() val legacyFavoriteIds = userPreferencesRepository.favoriteSongIdsFlow.first() if (legacyFavoriteIds.isNotEmpty()) { val roomFavoriteIds = musicRepository.getFavoriteSongIdsOnce() @@ -1677,8 +1663,17 @@ class PlayerViewModel @Inject constructor( // launchColorSchemeProcessor() - Handled by ThemeStateHolder and on-demand calls - loadPersistedDailyMix() - loadSearchHistory() + // Load persisted daily mix immediately so checkAndUpdateDailyMixIfNeeded() + // in onMainActivityStart() doesn't unnecessarily regenerate the mix. + viewModelScope.launch { + loadPersistedDailyMix() + } + + // Deferred: search history loads after first frame + viewModelScope.launch { + kotlinx.coroutines.delay(1500) + loadSearchHistory() + } viewModelScope.launch { isSyncingStateFlow.collect { isSyncing -> @@ -1797,7 +1792,7 @@ class PlayerViewModel @Inject constructor( aiStatus = status, aiError = error ) - }.collect { snapshot -> + }.distinctUntilChanged().collect { snapshot -> _playerUiState.update { it.copy( showAiPlaylistSheet = snapshot.showAiPlaylistSheet, @@ -1820,7 +1815,7 @@ class PlayerViewModel @Inject constructor( libraryStateHolder.isLoadingCategories, ) { folders, loadingLibrary, loadingCategories -> Triple(folders, loadingLibrary, loadingCategories) - }.collect { (folders, loadingLibrary, loadingCategories) -> + }.distinctUntilChanged().collect { (folders, loadingLibrary, loadingCategories) -> _playerUiState.update { it.copy( musicFolders = folders, @@ -1841,7 +1836,7 @@ class PlayerViewModel @Inject constructor( libraryStateHolder.currentFavoriteSortOption, ) { songSort, albumSort, artistSort, folderSort, favoriteSort -> SortOptionsSnapshot(songSort, albumSort, artistSort, folderSort, favoriteSort) - }.collect { snapshot -> + }.distinctUntilChanged().collect { snapshot -> _playerUiState.update { it.copy( currentSongSortOption = snapshot.songSort, diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/PlatformUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/PlatformUtils.kt new file mode 100644 index 000000000..c98723be6 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/PlatformUtils.kt @@ -0,0 +1,62 @@ +package com.theveloper.pixelplay.utils + +import android.app.ActivityManager +import android.content.Context +import android.os.Build + +object PlatformUtils { + fun isUbuntuTouch(): Boolean { + return try { + val properties = System.getProperties() + properties.getProperty("os.name", "") + .contains("ubuntu", ignoreCase = true) || + properties.getProperty("os.version", "") + .contains("ubuntu", ignoreCase = true) || + Build.DISPLAY.contains("ubuntu", ignoreCase = true) || + Build.HOST?.contains("ubuntu", ignoreCase = true) == true || + Build.FINGERPRINT?.contains("ubuntu", ignoreCase = true) == true || + Build.MODEL?.contains("ubuntu", ignoreCase = true) == true + } catch (_: Exception) { + false + } + } + + fun isWaydroid(): Boolean { + return try { + val properties = System.getProperties() + properties.getProperty("java.vendor.url", "") + .contains("waydroid", ignoreCase = true) || + Build.HOST?.contains("waydroid", ignoreCase = true) == true + } catch (_: Exception) { + false + } + } + + fun isRunningOnLinux(): Boolean { + return try { + val osName = System.getProperty("os.name") ?: "" + osName.contains("linux", ignoreCase = true) + } catch (_: Exception) { + false + } + } + + fun isEmulatedStorage(): Boolean { + return Build.DEVICE?.contains("generic", ignoreCase = true) == true || + Build.PRODUCT?.contains("generic", ignoreCase = true) == true || + Build.HARDWARE?.contains("goldfish", ignoreCase = true) == true || + Build.FINGERPRINT?.contains("generic", ignoreCase = true) == true + } + + fun isLowRamDevice(context: Context): Boolean = runCatching { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + am.isLowRamDevice + }.getOrDefault(false) + + fun totalMemoryMb(context: Context): Long = runCatching { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val info = ActivityManager.MemoryInfo() + am.getMemoryInfo(info) + info.totalMem / (1024 * 1024) + }.getOrDefault(0L) +} diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index afced2d2d..2e16c2669 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -14,6 +14,8 @@ الإيماءات، التفاعل اللمسي، وسلوك التنقل دمج الذكاء الاصطناعي (β) مزودو الذكاء الاصطناعي، مفاتيح الـ API، وإعدادات النموذج + Generation Parameters + Temperature, sampling, prompts, and song data configuration النسخ الاحتياطي والاستعادة تصدير واستعادة بيانات تطبيقك الشخصية خيارات المطورين diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml index 147d337ad..499298ea1 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -13,6 +13,8 @@ Gesten, Haptik und Navigation KI-Integration (β) Anbieter, API Keys und Modelle + Generation Parameters + Temperature, sampling, prompts, and song data configuration Backup & Wiederherstellen App-Daten exportieren und importieren Entwickleroptionen diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index 79a7e6257..17e83ca72 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -11,6 +11,8 @@ Gestos, vibración y navegación Integración IA (β) Proveedores de IA, claves API y modelos + Generation Parameters + Temperature, sampling, prompts, and song data configuration Copia y restauración Exporta y recupera tus datos personales Opciones de desarrollador diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 5932d3935..97d37134f 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -11,6 +11,8 @@ Gestes, retour haptique et comportement de navigation Intégration IA (β) Fournisseurs d\'IA, clés API et paramètres du modèle + Generation Parameters + Temperature, sampling, prompts, and song data configuration Sauvegarde et restauration Exporter et récupérer vos données personnelles de l\'application Options développeur diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml index cabbc3ab0..ca85cc981 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -11,6 +11,8 @@ Gerakan, haptik, dan perilaku navigasi Integrasi AI (β) Penyedia AI, kunci API, dan pengaturan model + Generation Parameters + Temperature, sampling, prompts, and song data configuration Cadangkan & Pulihkan Ekspor dan pulihkan data aplikasi pribadi Anda Opsi Pengembang diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml index 24ad47887..ef2d3b4e1 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -11,6 +11,8 @@ Gesti, feedback aptico e comportamento navigazione Integrazione AI (β) Provider AI, chiavi API e impostazioni modello + Generation Parameters + Temperature, sampling, prompts, and song data configuration Backup e ripristino Esporta e recupera i tuoi dati personali dell\'app Opzioni sviluppatore diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml index da2e36e8d..0245af98b 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -13,6 +13,8 @@ 제스처, 햅틱 및 내비게이션 동작 AI 연동 (β) AI 제공업체, API 키 및 모델 설정 + Generation Parameters + Temperature, sampling, prompts, and song data configuration 백업 및 복구 개인 앱 데이터 내보내기 및 복구 개발자 옵션 diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml index 0295cb20a..94d4646c8 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -13,6 +13,8 @@ Gester, haptikk og navigasjonsatferd AI-integrasjon (β) AI-leverandører, API-nøkler og modellinnstillinger + Generation Parameters + Temperature, sampling, prompts, and song data configuration Sikkerhetskopiering og gjenoppretting Eksporter og gjenopprett dine personlige app-data Utvikleralternativer diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index faceb539a..ac8a32c45 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -13,6 +13,8 @@ Жесты, тактильный отклик и навигация Интеграция ИИ (β) Поставщики ИИ, API-ключи и настройки моделей + Generation Parameters + Temperature, sampling, prompts, and song data configuration Резервное копирование и восстановление Экспорт и восстановление персональных данных приложения Параметры разработчика diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 71b05a38e..97e2ccfed 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -11,6 +11,8 @@ Hareketler, dokunsal geri bildirim ve gezinme davranışı Yapay Zeka Entegrasyonu (β) YZ sağlayıcıları, API anahtarları ve model ayarları + Generation Parameters + Temperature, sampling, prompts, and song data configuration Yedekle ve Geri Yükle Kişisel uygulama verilerinizi dışa aktarın ve kurtarın Geliştirici Seçenekleri diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index daf7d4eac..6f608f006 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -11,6 +11,8 @@ 手势、触感反馈和导航行为 AI 集成 (β) AI 提供商、API 密钥和模型设置 + Generation Parameters + Temperature, sampling, prompts, and song data configuration 备份与恢复 导出和恢复您的个人应用数据 开发者选项 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index b7f5cc4f5..feb3fb6a3 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -12,7 +12,9 @@ Behavior Gestures, haptics, and navigation behavior AI Integration (β) - AI providers, API keys, and model settings + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Backup & Restore Export and recover your personal app data Developer Options diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt index 7996811c6..5ff639014 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt @@ -205,7 +205,6 @@ class PlayerViewModelTest { every { mockMusicRepository.getAudioFiles() } returns flowOf(emptyList()) every { mockMusicRepository.getDistinctAlbumArtSongs() } returns flowOf(emptyList()) every { mockMusicRepository.getHomeMixPreviewSongs(any()) } returns flowOf(emptyList()) - every { mockMusicRepository.getSongCountFlow() } returns flowOf(0) every { mockMusicRepository.getCloudSongCountFlow() } returns flowOf(0) every { mockMusicRepository.searchSongs(any(), any()) } returns flowOf(emptyList()) every { mockMusicRepository.getMusicByGenre(any()) } returns flowOf(emptyList())