From 66be53cc21f3d18aa3e49faa91b37e0daf359663 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:39:46 +0545 Subject: [PATCH 01/20] perf: defer legacy migrations and non-critical loads out of init hot path - Merge migrateTabOrder, ensureLibrarySortDefaults, and legacy favorite migration into a single deferred coroutine (2s delay) - Defer loadPersistedDailyMix and loadSearchHistory to after first frame (1.5s delay) - This prevents DataStore first() calls and DB queries from blocking the critical init path that feeds first-frame rendering --- .../presentation/viewmodel/PlayerViewModel.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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..2936fa01f 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 @@ -1527,15 +1527,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 +1673,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 -> From b4689a63ba2ef5493276c58add5dd5cd78f14fcc Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:41:20 +0545 Subject: [PATCH 02/20] perf: serialize library data loading to avoid I/O contention on slow storage - Load songs first (most critical tab, needed for Songs tab rendering) - Chain albums after songs, artists after albums, folders after artists using Job.join() to prevent 4 simultaneous Room queries from competing for limited I/O bandwidth on eMMC storage (Redmi) - Reduces GC pressure from concurrent heap allocations --- .../presentation/viewmodel/LibraryStateHolder.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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..dbafa307b 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,22 +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 { + songsJob?.join() _isLoadingCategories.value = true @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) kotlinx.coroutines.flow.combine( @@ -292,6 +291,7 @@ class LibraryStateHolder @Inject constructor( } artistsJob = scope?.launch { + albumsJob?.join() _isLoadingCategories.value = true @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> @@ -306,6 +306,7 @@ class LibraryStateHolder @Inject constructor( } foldersJob = scope?.launch { + artistsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getMusicFolders(effectiveFoldersStorageFilter(filter)) From 37ef4d2a0d106649497dafeab742eddc1ed7de7c Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:42:30 +0545 Subject: [PATCH 03/20] perf: derive isLibraryContentEmpty from in-memory state instead of Room queries - Replace songCountFlow (Room query) with allSongsFlow (in-memory) - Now all 3 flows in the combine are in-memory StateFlows, not Room queries - Eliminates redundant SELECT COUNT(*) query that re-emitted on every songs table write during sync --- .../pixelplay/presentation/screens/LibraryScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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) From 7b9407de5da42e0b8fa6102b9ca878be2a973828 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:43:07 +0545 Subject: [PATCH 04/20] perf: eliminate loading state flapping during sequential library load - Remove individual _isLoadingCategories toggles from albums and artists jobs (they no longer manage their own loading state) - Add a single watcher coroutine that sets _isLoadingCategories = true when songs complete, then false when all sequential jobs finish - Prevents 3 redundant recompositions of LibraryScreen through playerUiState during initial data load --- .../presentation/viewmodel/LibraryStateHolder.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 dbafa307b..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 @@ -272,7 +272,6 @@ class LibraryStateHolder @Inject constructor( // I/O contention on slower storage (eMMC on Redmi vs UFS on Samsung). albumsJob = scope?.launch { songsJob?.join() - _isLoadingCategories.value = true @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) kotlinx.coroutines.flow.combine( effectiveStorageFilter, @@ -286,13 +285,11 @@ class LibraryStateHolder @Inject constructor( sortAlbumsList(albums, _currentAlbumSortOption.value).toImmutableList() } _albums.value = sortedAlbums - _isLoadingCategories.value = false } } artistsJob = scope?.launch { albumsJob?.join() - _isLoadingCategories.value = true @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getArtists(filter) @@ -301,7 +298,6 @@ class LibraryStateHolder @Inject constructor( sortArtistsList(artists, _currentArtistSortOption.value).toImmutableList() } _artists.value = sortedArtists - _isLoadingCategories.value = false } } @@ -317,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 From 898f7564b8f2a3e43b0dbbf1eb359b654ad91c22 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:43:46 +0545 Subject: [PATCH 05/20] perf: add distinctUntilChanged to playerUiState combine collectors - Library folders/loading combine: skip update when Triple unchanged - Sort options combine: skip update when SortOptionsSnapshot unchanged - AiUiSnapshot combine: skip update when snapshot unchanged - Prevents cascading recompositions of LibraryScreen from redundant playerUiState emissions during multiple rapid state changes --- .../pixelplay/presentation/viewmodel/PlayerViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 2936fa01f..a08f4727a 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 @@ -1797,7 +1797,7 @@ class PlayerViewModel @Inject constructor( aiStatus = status, aiError = error ) - }.collect { snapshot -> + }.distinctUntilChanged().collect { snapshot -> _playerUiState.update { it.copy( showAiPlaylistSheet = snapshot.showAiPlaylistSheet, @@ -1820,7 +1820,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 +1841,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, From 80fb3bf67e6c77fba11e51a7b1c1ce1449d3a484 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:48:09 +0545 Subject: [PATCH 06/20] feat(ai): add LYRICS prompt type for proper logging of translation requests - Add LYRICS to AiSystemPromptType enum - Add LYRICS case to AiSystemPromptEngine.buildPrompt with role/strategy - Update translateLyrics in AiStateHolder to use AiSystemPromptType.LYRICS - Add 'Lyrics' display label to formatPromptType in AiUsageComponents - Lyrics translation requests now appear distinctly in AI activity logs --- .../pixelplay/data/ai/AiSystemPromptEngine.kt | 11 +++++++++++ .../presentation/screens/AiUsageComponents.kt | 1 + .../pixelplay/presentation/viewmodel/AiStateHolder.kt | 8 ++++---- 3 files changed, 16 insertions(+), 4 deletions(-) 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/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/viewmodel/AiStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt index ad68cea83..2f1546989 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 @@ -287,7 +287,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,10 +307,10 @@ 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) From d799cd9e8e64c1f4cd99083f18d4cc19dd7263d2 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:50:38 +0545 Subject: [PATCH 07/20] feat(settings): split AI settings into AI Provider tab and Generation Parameters tab - Add GENERATION_PARAMETERS category to SettingsCategory enum with Tune icon - AI Integration tab now keeps only: provider selection, API key entry, model selection, base URL, and activity logs - New Generation Parameters tab gets: system prompt editor, temperature, top P, top K, max tokens, presence/frequency penalty, sample size, digest mode, and extended song fields - Add English string resources for the new category --- .../presentation/model/SettingsCategory.kt | 7 + .../screens/SettingsCategoryScreen.kt | 203 +++++++++--------- app/src/main/res/values/strings_settings.xml | 4 +- 3 files changed, 113 insertions(+), 101 deletions(-) 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/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index dd1672dbc..1e6c262b0 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 @@ -1037,6 +1037,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), @@ -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/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 From 08e941e3254382d6b6ff283230382d8bc2a58807 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:52:07 +0545 Subject: [PATCH 08/20] feat(ai): add SearchableProviderSelector with descriptions and live search - Add description field to AiProvider enum entries - Create SearchableProviderSelector composable similar to SearchableModelSelector with search, filtering, and count label - Replace ThemeSelectorItem in AI_INTEGRATION settings with SearchableProviderSelector for provider selection - Search covers name, displayName, and description fields --- .../pixelplay/data/ai/provider/AiProvider.kt | 29 +-- .../screens/SettingsCategoryScreen.kt | 8 +- .../screens/SettingsComponents.kt | 177 ++++++++++++++++++ 3 files changed, 198 insertions(+), 16 deletions(-) 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/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index 1e6c262b0..ad4810654 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 @@ -921,12 +921,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( 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..a4a656df4 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 @@ -551,6 +551,183 @@ fun SearchableModelSelector( } } +@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, From b10358a87a8b21678aa3f29e010b05d6f0a34fd9 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:54:08 +0545 Subject: [PATCH 09/20] i18n: add generation parameters strings to all 11 locale files - Update AI subtitle to reflect model selection and activity logs - Add GENERATION_PARAMETERS category strings to all locales - All new strings use English as fallback pending translator contributions --- .../pixelplay/utils/PlatformUtils.kt | 62 +++++++++++++++++++ .../main/res/values-ar/strings_settings.xml | 4 +- .../main/res/values-de/strings_settings.xml | 4 +- .../main/res/values-es/strings_settings.xml | 4 +- .../main/res/values-fr/strings_settings.xml | 4 +- .../main/res/values-in/strings_settings.xml | 4 +- .../main/res/values-it/strings_settings.xml | 4 +- .../main/res/values-ko/strings_settings.xml | 4 +- .../main/res/values-nb/strings_settings.xml | 4 +- .../main/res/values-ru/strings_settings.xml | 4 +- .../main/res/values-tr/strings_settings.xml | 4 +- .../res/values-zh-rCN/strings_settings.xml | 4 +- 12 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/utils/PlatformUtils.kt 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 备份与恢复 导出和恢复您的个人应用数据 开发者选项 From 5b0444cd2f0c0c68d23a8c42c3bf1e6f9e6e87f1 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 21:57:27 +0545 Subject: [PATCH 10/20] cleanup: remove unused hasGeminiApiKey, songCountFlow, and repository method - Remove dead hasGeminiApiKey StateFlow in PlayerViewModel - Remove dead songCountFlow StateFlow in PlayerViewModel - Remove unused getSongCountFlow() from MusicRepository interface and impl - Remove corresponding mock from PlayerViewModelTest --- .../pixelplay/data/repository/MusicRepository.kt | 6 ------ .../data/repository/MusicRepositoryImpl.kt | 4 ---- .../presentation/viewmodel/PlayerViewModel.kt | 15 --------------- .../presentation/viewmodel/PlayerViewModelTest.kt | 1 - 4 files changed, 26 deletions(-) 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/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index a08f4727a..1c0100c96 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() 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()) From 273f6d95f9aecfdde75e47fd69653360f7c30141 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 22:20:38 +0545 Subject: [PATCH 11/20] fix: add missing LYRICS and GENERATION_PARAMETERS branches in when expressions - Add LYRICS case to temperature selection in AiHandler.kt - Add GENERATION_PARAMETERS case to category colors in SettingsScreen.kt (both dark and light color schemes) --- app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt | 1 + .../theveloper/pixelplay/presentation/screens/SettingsScreen.kt | 2 ++ 2 files changed, 3 insertions(+) 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..f21b6992e 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,6 +171,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 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) From 61702e2e0e14508b108a46af1184dde1a4fc7991 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 22:47:16 +0545 Subject: [PATCH 12/20] fix: add missing @OptIn for ExperimentalMaterial3Api on SearchableProviderSelector --- .../pixelplay/presentation/screens/SettingsComponents.kt | 1 + 1 file changed, 1 insertion(+) 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 a4a656df4..420fcd422 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 @@ -551,6 +551,7 @@ fun SearchableModelSelector( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchableProviderSelector( label: String, From 51209dc3d4a33c0f3144238eab05052a693824d6 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Sun, 28 Jun 2026 22:54:46 +0545 Subject: [PATCH 13/20] fix: use file-level @OptIn for ExperimentalMaterial3Api in SettingsComponents --- .../pixelplay/presentation/screens/SettingsComponents.kt | 2 ++ 1 file changed, 2 insertions(+) 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 420fcd422..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 From e3e1b19e31594c8b26c686ac453cafbc098f371b Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 00:35:23 +0545 Subject: [PATCH 14/20] fix(ai): add live status feedback and fix toast buffer for AI generation - Increase toast buffer from 1 to 5 to prevent rapid status/error drops - Add status display card to CreateAiPlaylistDialog showing real-time AI progress ("Analyzing...", "Selecting...", etc.) - Pass aiStatus from LibraryScreen to CreateAiPlaylistDialog - Fix missing import for RoundedCornerShape --- .../components/PlaylistCreationDialogs.kt | 26 +++++++++++++++++++ .../presentation/screens/LibraryScreen.kt | 2 ++ .../presentation/viewmodel/PlayerViewModel.kt | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) 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/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 3c9a6590a..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) } @@ -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/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 1c0100c96..00eb6bd26 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 @@ -638,7 +638,7 @@ class PlayerViewModel @Inject constructor( // Toast Events private val _toastEvents = MutableSharedFlow( - extraBufferCapacity = 1, + extraBufferCapacity = 5, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val toastEvents = _toastEvents.asSharedFlow() From c8c06dbc2b3515c0d4983ad3f199cd5db6528d05 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 00:55:51 +0545 Subject: [PATCH 15/20] fix(search): eliminate tab-switch lag by decoupling from monolithic playerUiState - Expose searchResults, selectedSearchFilter, searchHistory directly from PlayerViewModel instead of deriving from 40-field playerUiState - Defer genre loading to LaunchedEffect gated by showGenreBrowse, avoiding heavy SELECT DISTINCT genre query on every navigation - Guard LaunchedEffect to skip performSearch for empty queries - Remove unused SearchUiSlice wrapper and flow imports --- .../presentation/screens/SearchScreen.kt | 37 ++++++++----------- .../presentation/viewmodel/PlayerViewModel.kt | 7 ++++ 2 files changed, 22 insertions(+), 22 deletions(-) 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..8ce90d4bb 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.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/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 00eb6bd26..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() From b516f839fe6a9849f88df7b7d3ec7b617d5a7042 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 00:57:23 +0545 Subject: [PATCH 16/20] feat(ai): update all provider names, descriptions, endpoints, and default models - Update AiProvider descriptions to reflect latest model families - Ollama: change to requiresApiKey=true with configurable URL (cloud) - DeepSeek: update endpoint to /v1, default deepseek-chat - Groq: update default to llama-3.3-70b-versatile - Mistral: update default to mistral-large-2411 - NVIDIA: update default to nemotron-70b-instruct - Kimi: update default to moonshot-v1-auto - GLM: update default to glm-4-plus - OpenRouter: update default to gemini-2.5-flash-preview free - Ollama: clear default URL (user configures their endpoint) - Allow empty API key for providers with requiresApiKey=false --- .../data/ai/provider/AiClientFactory.kt | 24 +++++++++---------- .../pixelplay/data/ai/provider/AiProvider.kt | 23 ++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) 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..13c6a2568 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,13 +67,13 @@ 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( apiKey = apiKey, - baseUrl = "https://api.ollama.ai/v1", - defaultModelId = "llama3", + baseUrl = "", + defaultModelId = "", providerName = "Ollama" ) AiProvider.CUSTOM -> 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 31c2a0d39..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,24 +1,21 @@ 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, 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)"), + 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 { From 1d83e43f21099ca76baa59c398c0d6ac4ba733e8 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 00:58:35 +0545 Subject: [PATCH 17/20] fix(ai): clean up stale error propagation using ConcurrentHashMap for cooldowns - Replace mutableMapOf with ConcurrentHashMap for thread-safe cooldown tracking - Clean up expired cooldown entries on each generateContent call to prevent stale entries accumulating over time and blocking all providers - Reduce cooldown from 5 minutes to 2 minutes for faster recovery - Skip API key check for providers that don't require one --- .../com/theveloper/pixelplay/data/ai/AiHandler.kt | 14 +++++++++----- .../pixelplay/data/ai/provider/AiClientFactory.kt | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) 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 f21b6992e..0a47a0446 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 @@ -192,9 +193,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 @@ -205,7 +209,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 } 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 13c6a2568..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 @@ -72,8 +72,8 @@ class AiClientFactory @Inject constructor() { ) AiProvider.OLLAMA -> GenericOpenAiClient( apiKey = apiKey, - baseUrl = "", - defaultModelId = "", + baseUrl = "https://api.ollama.ai/v1", + defaultModelId = "llama3", providerName = "Ollama" ) AiProvider.CUSTOM -> GenericOpenAiClient( From c0d06147e5e9cddfe04538fe3c7735fc741386a5 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 01:09:04 +0545 Subject: [PATCH 18/20] fix: add missing import for kotlinx.coroutines.flow.first --- .../theveloper/pixelplay/presentation/screens/SearchScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8ce90d4bb..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,7 +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.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape From fa9f4997bf41de472366a4fbc434dc1a14062bcc Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 01:22:29 +0545 Subject: [PATCH 19/20] fix(ai): update AI client creation to support configurable base URLs and streamline base URL handling in settings --- .../theveloper/pixelplay/data/ai/AiHandler.kt | 8 +- .../screens/SettingsCategoryScreen.kt | 6 +- .../viewmodel/SettingsViewModel.kt | 83 +------------------ 3 files changed, 13 insertions(+), 84 deletions(-) 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 0a47a0446..c74e56b13 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 @@ -98,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 { 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 ad4810654..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,7 +917,7 @@ 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)) { @@ -1029,8 +1029,8 @@ 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" ) 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) } } From 2b509c2f6b73efb0959f61de3c4b20849b2bd256 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 29 Jun 2026 01:28:26 +0545 Subject: [PATCH 20/20] fix(ai): strip markdown formatting from lyrics responses and dynamically scale maxTokens - Enhanced AiResponseCleaner.cleanTextResponse to strip paired **text**/__text__ markers - Strip conversational framing lines prepended by AI (Here is, Sure!, etc.) - Apply cleaning in AiStateHolder.translateLyrics before returning response - Dynamically scale maxTokens for LYRICS type based on input size (2x+ output, 4096 min, 16384 max) - Prevents aggressive cut-off and formatting markers in AI-translated lyrics --- .../theveloper/pixelplay/data/ai/AiHandler.kt | 14 +++++++++++++- .../pixelplay/data/ai/AiResponseCleaner.kt | 19 ++++++++++++++++++- .../presentation/viewmodel/AiStateHolder.kt | 4 +++- 3 files changed, 34 insertions(+), 3 deletions(-) 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 c74e56b13..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 @@ -170,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) { @@ -231,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/presentation/viewmodel/AiStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt index 2f1546989..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 @@ -313,7 +314,8 @@ $lyricsText type = AiSystemPromptType.LYRICS, temperature = 0.1f ) - Result.success(response) + val cleaned = AiResponseCleaner.cleanTextResponse(response) + Result.success(cleaned) } catch (e: Exception) { Result.failure(e) }