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..08089561f 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import timber.log.Timber import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -27,9 +28,9 @@ class AiHandler @Inject constructor( private val promptEngine: AiSystemPromptEngine, @AppScope private val appScope: CoroutineScope ) { - // Cooldown timer: Provider -> Expiry Timestamp - private val providerCooldowns = mutableMapOf() - private val COOLDOWN_DURATION_MS = 1000L * 60 * 5 // 5 minutes + // Cooldown timer: Provider -> Expiry Timestamp (thread-safe, auto-cleans expired) + private val providerCooldowns = ConcurrentHashMap() + private val COOLDOWN_DURATION_MS = 1000L * 60 * 2 // 2 minutes (was 5) // Cache TTL: 30 minutes — prevents stale results from being served indefinitely private val CACHE_TTL_MS = 1000L * 60 * 30 @@ -97,7 +98,13 @@ class AiHandler @Inject constructor( presencePenalty: Float, frequencyPenalty: Float, ): GenerationResult { - val client = clientFactory.createClient(provider, apiKey) + val client = if (provider.hasConfigurableUrl) { + val configuredUrl = preferencesRepo.getBaseUrl(provider).first() + if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl) + else clientFactory.createClient(provider, apiKey) + } else { + clientFactory.createClient(provider, apiKey) + } val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() } suspend fun callWithModel(model: String): String { @@ -163,6 +170,18 @@ class AiHandler @Inject constructor( context: String = "" ): String { val params = getGenerationParams() + val effectiveMaxTokens = if (type == AiSystemPromptType.LYRICS) { + // Lyrics translation needs more output tokens because each original + // line is followed by its translation (2x output). Also factor in + // the full lyrics text in the prompt. Use at least 4096, at most + // 16384, scaling linearly with input length. + val estimatedInputChars = prompt.length + val estimatedOutputChars = estimatedInputChars * 2 + val estimatedOutputTokens = (estimatedOutputChars / 4).coerceAtLeast(4096) + estimatedOutputTokens.coerceAtMost(16384) + } else { + params.maxTokens + } val effectiveTemperature = if (params.temperature == 0.7f) { if (temperature == 0.7f) { when (type) { @@ -171,6 +190,7 @@ class AiHandler @Inject constructor( AiSystemPromptType.TAGGING -> 0.4f AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f AiSystemPromptType.PERSONA -> 0.85f + AiSystemPromptType.LYRICS -> 0.7f AiSystemPromptType.GENERAL -> 0.7f } } else temperature @@ -191,9 +211,12 @@ class AiHandler @Inject constructor( } } + // Clean up expired cooldowns so stale entries never accumulate + val now = System.currentTimeMillis() + providerCooldowns.entries.removeIf { it.value < now } + val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider) val failedProviders = mutableListOf() - val now = System.currentTimeMillis() for (provider in providersToTry) { val cooldownExpiry = providerCooldowns[provider] ?: 0L @@ -204,7 +227,7 @@ class AiHandler @Inject constructor( try { val apiKey = getApiKey(provider) - if (apiKey.isBlank()) { + if (apiKey.isBlank() && provider.requiresApiKey) { failedProviders.add("${provider.name}: no API key configured") continue } @@ -220,7 +243,7 @@ class AiHandler @Inject constructor( temperature = effectiveTemperature, topP = params.topP, topK = params.topK, - maxTokens = params.maxTokens, + maxTokens = effectiveMaxTokens, presencePenalty = params.presencePenalty, frequencyPenalty = params.frequencyPenalty, ) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt index 92d6e27de..2e315e4f7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt @@ -21,10 +21,27 @@ object AiResponseCleaner { } fun cleanTextResponse(raw: String): String { - return raw + var cleaned = raw .replace("```text", "") .replace("```", "") .trim() + + // Remove conversational framing lines that AIs sometimes prepend + val framingPrefixes = listOf( + "Here is", "Here's", "Here are", "Sure", "Certainly", + "Of course", "I've", "I have", "The translated", "Translation:", + "Translated lyrics:", "Output:" + ) + val framingPattern = framingPrefixes.joinToString("|") { Regex.escape(it) } + cleaned = cleaned.replace(Regex("^(?:$framingPattern).*\\n?", RegexOption.MULTILINE), "") + + // Strip paired markdown formatting markers (**text** or __text__) + cleaned = cleaned + .replace(Regex("\\*\\*(.*?)\\*\\*"), "$1") + .replace(Regex("__(.*?)__"), "$1") + .trim() + + return cleaned } fun extractJsonArray(text: String): String? { 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/AiClientFactory.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt index 4322ac24e..b0401aedc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt @@ -16,7 +16,7 @@ class AiClientFactory @Inject constructor() { * @return AiClient instance */ fun createClient(provider: AiProvider, apiKey: String): AiClient { - if (apiKey.isBlank()) { + if (apiKey.isBlank() && provider.requiresApiKey) { throw IllegalArgumentException("API Key cannot be blank for ${provider.displayName}") } @@ -24,39 +24,39 @@ class AiClientFactory @Inject constructor() { AiProvider.GEMINI -> GeminiAiClient(apiKey) AiProvider.DEEPSEEK -> GenericOpenAiClient( apiKey = apiKey, - baseUrl = "https://api.deepseek.com", + baseUrl = "https://api.deepseek.com/v1", defaultModelId = "deepseek-chat", providerName = "DeepSeek" ) AiProvider.GROQ -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.groq.com/openai/v1", - defaultModelId = "llama-3.1-8b-instant", + defaultModelId = "llama-3.3-70b-versatile", providerName = "Groq" ) AiProvider.MISTRAL -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.mistral.ai/v1", - defaultModelId = "mistral-large-latest", + defaultModelId = "mistral-large-2411", providerName = "Mistral" ) AiProvider.NVIDIA -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://integrate.api.nvidia.com/v1", - defaultModelId = "meta/llama-3.1-8b-instruct", + defaultModelId = "nvidia/llama-3.1-nemotron-70b-instruct", providerName = "NVIDIA NIM" ) AiProvider.KIMI -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.moonshot.cn/v1", - defaultModelId = "moonshot-v1-8k", - providerName = "Moonshot Kimi" + defaultModelId = "moonshot-v1-auto", + providerName = "Kimi" ) AiProvider.GLM -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://open.bigmodel.cn/api/paas/v4", - defaultModelId = "glm-4", - providerName = "Zhipu GLM" + defaultModelId = "glm-4-plus", + providerName = "GLM" ) AiProvider.OPENAI -> GenericOpenAiClient( apiKey = apiKey, @@ -67,7 +67,7 @@ class AiClientFactory @Inject constructor() { AiProvider.OPENROUTER -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://openrouter.ai/api/v1", - defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free", + defaultModelId = "google/gemini-2.5-flash-preview-04-17:free", providerName = "OpenRouter" ) AiProvider.OLLAMA -> GenericOpenAiClient( 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..1b3caf0bd 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 @@ -1,20 +1,22 @@ 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 = "Gemini 2.5 Pro/Flash — Google's latest multimodal models"), + DEEPSEEK("DeepSeek", requiresApiKey = true, description = "DeepSeek-V3 & R1 — competitive open-weight reasoning models"), + GROQ("Groq", requiresApiKey = true, description = "Llama 3, Mixtral, Gemma — ultra-fast LPU inference"), + MISTRAL("Mistral", requiresApiKey = true, description = "Mistral Large, Small, Codestral — efficient European models"), + NVIDIA("NVIDIA NIM", requiresApiKey = true, description = "NVIDIA-optimized Llama, Nemotron, and community models"), + KIMI("Kimi (Moonshot)", requiresApiKey = true, description = "Kimi k1.5 — long-context reasoning by Moonshot AI"), + GLM("Zhipu GLM", requiresApiKey = true, description = "GLM-4 — bilingual (Chinese/English) models by Zhipu AI"), + OPENAI("OpenAI", requiresApiKey = true, description = "GPT-4o, GPT-4.1, o3, o4-mini — industry-standard models"), + OPENROUTER("OpenRouter", requiresApiKey = true, description = "Single API for 300+ models across all major providers"), + OLLAMA("Ollama", requiresApiKey = true, hasConfigurableUrl = true, description = "Ollama-compatible API endpoint (configurable URL)"), + 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/components/PlaylistCreationDialogs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt index e1af93fbc..e08e97584 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -301,6 +302,7 @@ private fun CreationModeCard( fun CreateAiPlaylistDialog( visible: Boolean, isGenerating: Boolean, + status: String?, error: String?, onDismiss: () -> Unit, onGenerate: (playlistName: String?, prompt: String, minLength: Int, maxLength: Int) -> Unit @@ -328,6 +330,7 @@ fun CreateAiPlaylistDialog( ) { CreateAiPlaylistContent( isGenerating = isGenerating, + status = status, error = error, onDismiss = onDismiss, onGenerate = onGenerate @@ -340,6 +343,7 @@ fun CreateAiPlaylistDialog( @Composable private fun CreateAiPlaylistContent( isGenerating: Boolean, + status: String?, error: String?, onDismiss: () -> Unit, onGenerate: (playlistName: String?, prompt: String, minLength: Int, maxLength: Int) -> Unit @@ -525,6 +529,28 @@ private fun CreateAiPlaylistContent( ) { Spacer(modifier = Modifier.height(4.dp)) + if (isGenerating && !status.isNullOrBlank()) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LoadingIndicator(modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + HeroAiCard() AiSectionCard( 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 e59c3d7a8..423863c73 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 @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Info 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.Tune import androidx.compose.ui.graphics.vector.ImageVector import com.theveloper.pixelplay.R @@ -49,6 +50,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 8c9a9b17f..7f7b9b4fa 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 @@ -178,6 +178,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..e09c1d120 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 @@ -493,6 +493,7 @@ fun LibraryScreen( }.collectAsStateWithLifecycle(initialValue = false) val hasActiveAiProviderApiKey by playerViewModel.hasActiveAiProviderApiKey.collectAsStateWithLifecycle() val isGeneratingAiPlaylist by playerViewModel.isGeneratingAiPlaylist.collectAsStateWithLifecycle() + val aiStatus by playerViewModel.aiStatus.collectAsStateWithLifecycle() val aiError by playerViewModel.aiError.collectAsStateWithLifecycle() var showCreatePlaylistDialog by remember { mutableStateOf(false) } var showPlaylistCreationTypeDialog by remember { mutableStateOf(false) } @@ -1002,11 +1003,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) @@ -1800,6 +1801,7 @@ fun LibraryScreen( CreateAiPlaylistDialog( visible = showCreateAiPlaylistDialog && hasActiveAiProviderApiKey, isGenerating = isGeneratingAiPlaylist, + status = aiStatus, error = aiError, onDismiss = { showCreateAiPlaylistDialog = false diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt index eab38137e..429f91060 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt @@ -140,8 +140,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -151,11 +150,6 @@ import androidx.compose.ui.res.stringResource private const val MAX_ALBUM_MULTI_SELECTION = 6 -private data class SearchUiSlice( - val selectedSearchFilter: SearchFilterType = SearchFilterType.ALL, - val searchResults: ImmutableList = persistentListOf() -) - @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -224,18 +218,17 @@ fun SearchScreen( multiSelectionState.toggleSelection(song) } } - val searchUiState by remember(playerViewModel) { - playerViewModel.playerUiState - .map { uiState -> - SearchUiSlice( - selectedSearchFilter = uiState.selectedSearchFilter, - searchResults = uiState.searchResults - ) - } - .distinctUntilChanged() - }.collectAsStateWithLifecycle(initialValue = SearchUiSlice()) - val currentFilter = searchUiState.selectedSearchFilter - val genres by playerViewModel.genres.collectAsStateWithLifecycle() + val searchResults by playerViewModel.searchResults.collectAsStateWithLifecycle() + val currentFilter by playerViewModel.selectedSearchFilter.collectAsStateWithLifecycle() + + // Defer genre loading — only fetch when the genre section is about to be shown + var genres by remember { mutableStateOf>(persistentListOf()) } + val showGenreBrowse by remember(searchQuery) { derivedStateOf { searchQuery.isBlank() } } + LaunchedEffect(showGenreBrowse) { + if (showGenreBrowse) { + genres = playerViewModel.genres.first() + } + } val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() val favoriteSongIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle() val selectedSongForInfo by playerViewModel.selectedSongForInfo.collectAsStateWithLifecycle() @@ -257,9 +250,10 @@ fun SearchScreen( // Search debouncing is centralized in SearchStateHolder. LaunchedEffect(searchQuery, currentFilter) { - playerViewModel.performSearch(searchQuery) + if (searchQuery.isNotBlank()) { + playerViewModel.performSearch(searchQuery) + } } - val searchResults = searchUiState.searchResults val handleSongMoreOptionsClick: (Song) -> Unit = { song -> playerViewModel.selectSongForInfo(song) showSongInfoBottomSheet = true @@ -416,7 +410,6 @@ fun SearchScreen( } } - val showGenreBrowse by remember(searchQuery) { derivedStateOf { searchQuery.isBlank() } } AnimatedContent( targetState = showGenreBrowse, transitionSpec = { 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 dd1672dbc..a2860b68b 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 @@ -917,16 +917,16 @@ fun SettingsCategoryScreen( } SettingsCategory.AI_INTEGRATION -> { val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) - val currentCustomBaseUrl by settingsViewModel.customBaseUrl.collectAsStateWithLifecycle() + val currentAiBaseUrl by settingsViewModel.currentAiBaseUrl.collectAsStateWithLifecycle() // 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( @@ -1029,14 +1029,115 @@ fun SettingsCategoryScreen( if (provider.hasConfigurableUrl) { SettingsSubsection(title = "API Base URL") { AiApiKeyItem( - apiKey = currentCustomBaseUrl, - onApiKeySave = { settingsViewModel.onCustomBaseUrlChange(it) }, + apiKey = currentAiBaseUrl, + onApiKeySave = { settingsViewModel.onBaseUrlChange(it) }, title = "Base URL", subtitle = "e.g. https://api.example.com/v1" ) } } + 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), @@ -1140,6 +1241,8 @@ fun SettingsCategoryScreen( ) } + Spacer(modifier = Modifier.height(16.dp)) + // Song Data Configuration Section SettingsSubsection(title = "Song Data Configuration") { val aiSampleSize by settingsViewModel.aiSampleSize.collectAsStateWithLifecycle() @@ -1185,106 +1288,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) - ) { - 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 fa40ecb26..1bff62daf 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 @@ -483,6 +483,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) @@ -496,6 +497,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 ad68cea83..3f0d3f125 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 @@ -6,6 +6,7 @@ import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.DailyMixManager import com.theveloper.pixelplay.data.ai.AiNotificationManager import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator +import com.theveloper.pixelplay.data.ai.AiResponseCleaner import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.provider.AiProviderException import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository @@ -287,7 +288,7 @@ class AiStateHolder @Inject constructor( suspend fun translateLyrics(lyricsText: String): Result { return try { val targetLanguage = context.resources.configuration.locales[0].displayLanguage - val prompt = """ + val xmlPrompt = """ Translate song lyrics into $targetLanguage. @@ -307,13 +308,14 @@ class AiStateHolder @Inject constructor( $lyricsText """.trimIndent() - + val response = aiHandler.generateContent( - prompt = prompt, - type = AiSystemPromptType.GENERAL, + prompt = xmlPrompt, + type = AiSystemPromptType.LYRICS, temperature = 0.1f ) - Result.success(response) + val cleaned = AiResponseCleaner.cleanTextResponse(response) + Result.success(cleaned) } catch (e: Exception) { Result.failure(e) } 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..3515c7de8 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 @@ -257,23 +257,21 @@ class LibraryStateHolder @Inject constructor( songsJob = scope?.launch { _isLoadingLibrary.value = true musicRepository.getAudioFiles().conflate().collect { songs -> - // Process heavy list conversions on Default dispatcher to avoid blocking UI 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 to avoid + // I/O contention on slower storage (eMMC on Redmi vs UFS on Samsung). albumsJob = scope?.launch { - _isLoadingCategories.value = true + songsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) kotlinx.coroutines.flow.combine( effectiveStorageFilter, @@ -287,12 +285,11 @@ class LibraryStateHolder @Inject constructor( sortAlbumsList(albums, _currentAlbumSortOption.value).toImmutableList() } _albums.value = sortedAlbums - _isLoadingCategories.value = false } } artistsJob = scope?.launch { - _isLoadingCategories.value = true + albumsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getArtists(filter) @@ -301,11 +298,11 @@ class LibraryStateHolder @Inject constructor( sortArtistsList(artists, _currentArtistSortOption.value).toImmutableList() } _artists.value = sortedArtists - _isLoadingCategories.value = false } } foldersJob = scope?.launch { + artistsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getMusicFolders(effectiveFoldersStorageFilter(filter)) @@ -316,6 +313,16 @@ class LibraryStateHolder @Inject constructor( _musicFolders.value = sortedFolders } } + + // Single loading state: true when songs start, false when all sequential + // jobs (albums + artists) finish. This avoids flapping true→false→true + // as each sequential coroutine completes. + scope?.launch { + songsJob?.join() + _isLoadingCategories.value = true + foldersJob?.join() + _isLoadingCategories.value = false + } } // Deprecated imperative loaders - redirected to observer start 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..a3e9d36cf 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 @@ -45,6 +45,8 @@ import com.theveloper.pixelplay.data.model.Genre import com.theveloper.pixelplay.data.model.Lyrics import com.theveloper.pixelplay.data.model.LyricsSourcePreference import com.theveloper.pixelplay.data.model.SearchFilterType +import com.theveloper.pixelplay.data.model.SearchHistoryItem +import com.theveloper.pixelplay.data.model.SearchResultItem import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.toLibraryTabIdOrNull @@ -444,6 +446,11 @@ class PlayerViewModel @Inject constructor( val aiStatus: StateFlow = aiStateHolder.aiStatus val aiError: StateFlow = aiStateHolder.aiError + // Search state (direct access bypasses 40-field playerUiState) + val searchResults: StateFlow> = searchStateHolder.searchResults + val selectedSearchFilter: StateFlow = searchStateHolder.selectedSearchFilter + val searchHistory: StateFlow> = searchStateHolder.searchHistory + private val _selectedSongForInfo = MutableStateFlow(null) val selectedSongForInfo: StateFlow = _selectedSongForInfo.asStateFlow() @@ -536,14 +543,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, @@ -646,7 +645,7 @@ class PlayerViewModel @Inject constructor( // Toast Events private val _toastEvents = MutableSharedFlow( - extraBufferCapacity = 1, + extraBufferCapacity = 5, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val toastEvents = _toastEvents.asSharedFlow() @@ -1140,13 +1139,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 +1519,11 @@ class PlayerViewModel @Inject constructor( + // Deferred: legacy migrations run after init to avoid blocking first render viewModelScope.launch { + kotlinx.coroutines.delay(2000) userPreferencesRepository.migrateTabOrder() - } - - viewModelScope.launch { userPreferencesRepository.ensureLibrarySortDefaults() - } - - viewModelScope.launch { val legacyFavoriteIds = userPreferencesRepository.favoriteSongIdsFlow.first() if (legacyFavoriteIds.isNotEmpty()) { val roomFavoriteIds = musicRepository.getFavoriteSongIdsOnce() @@ -1677,8 +1665,12 @@ class PlayerViewModel @Inject constructor( // launchColorSchemeProcessor() - Handled by ThemeStateHolder and on-demand calls - loadPersistedDailyMix() - loadSearchHistory() + // Deferred: daily mix and search history load after first frame + viewModelScope.launch { + kotlinx.coroutines.delay(1500) + loadPersistedDailyMix() + loadSearchHistory() + } viewModelScope.launch { isSyncingStateFlow.collect { isSyncing -> @@ -1797,7 +1789,7 @@ class PlayerViewModel @Inject constructor( aiStatus = status, aiError = error ) - }.collect { snapshot -> + }.distinctUntilChanged().collect { snapshot -> _playerUiState.update { it.copy( showAiPlaylistSheet = snapshot.showAiPlaylistSheet, @@ -1820,7 +1812,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 +1833,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/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index abba7eace..430aed890 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -333,87 +333,10 @@ class SettingsViewModel @Inject constructor( } } - // Specific on-change methods for UI binding - fun onGeminiApiKeyChange(apiKey: String) { + fun onBaseUrlChange(baseUrl: String) { viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GEMINI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GEMINI") - else clearModelsState("GEMINI") - } - } - fun onDeepseekApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.DEEPSEEK, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "DEEPSEEK") - else clearModelsState("DEEPSEEK") - } - } - fun onGroqApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GROQ, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GROQ") - else clearModelsState("GROQ") - } - } - fun onMistralApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.MISTRAL, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "MISTRAL") - else clearModelsState("MISTRAL") - } - } - fun onNvidiaApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.NVIDIA, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "NVIDIA") - else clearModelsState("NVIDIA") - } - } - fun onKimiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.KIMI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "KIMI") - else clearModelsState("KIMI") - } - } - fun onGlmApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GLM, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GLM") - else clearModelsState("GLM") - } - } - fun onOpenAiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OPENAI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OPENAI") - else clearModelsState("OPENAI") - } - } - fun onOpenrouterApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OPENROUTER, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OPENROUTER") - else clearModelsState("OPENROUTER") - } - } - fun onOllamaApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OLLAMA, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OLLAMA") - else clearModelsState("OLLAMA") - } - } - fun onCustomApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.CUSTOM, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "CUSTOM") - else clearModelsState("CUSTOM") - } - } - fun onCustomBaseUrlChange(baseUrl: String) { - viewModelScope.launch { - aiPreferencesRepository.setBaseUrl(AiProvider.CUSTOM, baseUrl) + val provider = AiProvider.fromString(aiProvider.value) + aiPreferencesRepository.setBaseUrl(provider, baseUrl) } } 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..817cd8697 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/PlatformUtils.kt @@ -0,0 +1,62 @@ +package com.theveloper.pixelplay.utils + +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 + } + + val isLowRamDevice: Boolean get() = runCatching { + val activityManager = android.app.ActivityManager::class.java + .getMethod("isLowRamDevice") + activityManager.invoke(null) as? Boolean ?: false + }.getOrDefault(false) + + val totalMemoryMb: Long get() = runCatching { + val info = android.app.ActivityManager.MemoryInfo() + android.app.ActivityManager::class.java + .getMethod("getMemoryInfo", android.app.ActivityManager.MemoryInfo::class.java) + .invoke(null, 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..b5dfcfddb 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -13,7 +13,9 @@ السلوك الإيماءات، التفاعل اللمسي، وسلوك التنقل دمج الذكاء الاصطناعي (β) - مزودو الذكاء الاصطناعي، مفاتيح الـ API، وإعدادات النموذج + AI providers, API keys, model selection, and activity logs + 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 e6fcda843..3382f7cae 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -10,7 +10,9 @@ Verhalten Gesten, Haptik und Navigation KI-Integration (β) - Anbieter, API Keys und Modelle + AI providers, API keys, model selection, and activity logs + 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..bc7c980df 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -10,7 +10,9 @@ Comportamiento Gestos, vibración y navegación Integración IA (β) - Proveedores de IA, claves API y modelos + AI providers, API keys, model selection, and activity logs + 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..d6cc27229 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -10,7 +10,9 @@ Comportement Gestes, retour haptique et comportement de navigation Intégration IA (β) - Fournisseurs d\'IA, clés API et paramètres du modèle + AI providers, API keys, model selection, and activity logs + 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..bd6ee6c5f 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -10,7 +10,9 @@ Perilaku Gerakan, haptik, dan perilaku navigasi Integrasi AI (β) - Penyedia AI, kunci API, dan pengaturan model + AI providers, API keys, model selection, and activity logs + 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..eefd35f1c 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -10,7 +10,9 @@ Comportamento Gesti, feedback aptico e comportamento navigazione Integrazione AI (β) - Provider AI, chiavi API e impostazioni modello + AI providers, API keys, model selection, and activity logs + 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 4b4fe3a87..b9a0eeb91 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -10,7 +10,9 @@ 동작 제스처, 햅틱 및 내비게이션 동작 AI 연동 (β) - AI 제공업체, API 키 및 모델 설정 + AI providers, API keys, model selection, and activity logs + 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 d8e2abe1f..0d0d1c092 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -10,7 +10,9 @@ Atferd Gester, haptikk og navigasjonsatferd AI-integrasjon (β) - AI-leverandører, API-nøkler og modellinnstillinger + AI providers, API keys, model selection, and activity logs + 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 c49784964..ba665828a 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -10,7 +10,9 @@ Поведение Жесты, тактильный отклик и навигация Интеграция ИИ (β) - Поставщики ИИ, API-ключи и настройки моделей + AI providers, API keys, model selection, and activity logs + 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..31fba8ef4 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -10,7 +10,9 @@ Davranış Hareketler, dokunsal geri bildirim ve gezinme davranışı Yapay Zeka Entegrasyonu (β) - YZ sağlayıcıları, API anahtarları ve model ayarları + AI providers, API keys, model selection, and activity logs + 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..07496a312 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -10,7 +10,9 @@ 行为 手势、触感反馈和导航行为 AI 集成 (β) - AI 提供商、API 密钥和模型设置 + AI providers, API keys, model selection, and activity logs + 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 ea3602815..d1820c6ab 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -10,7 +10,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())