From 402e45770039555e1f6898311c5832225f68c852 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 18:59:43 +0545 Subject: [PATCH 01/30] refactor(ai): rename AiOrchestrator to AiHandler Renamed the central AI orchestration class from AiOrchestrator to AiHandler and updated all references across the codebase. --- .../pixelplay/data/ai/{AiOrchestrator.kt => AiHandler.kt} | 8 ++++---- .../theveloper/pixelplay/data/ai/AiMetadataGenerator.kt | 4 ++-- .../theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt | 4 ++-- .../theveloper/pixelplay/data/ai/GeminiModelService.kt | 4 ++-- .../pixelplay/data/preferences/AiPreferencesRepository.kt | 2 +- .../java/com/theveloper/pixelplay/data/worker/AiWorker.kt | 6 +++--- .../pixelplay/presentation/viewmodel/AiStateHolder.kt | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) rename app/src/main/java/com/theveloper/pixelplay/data/ai/{AiOrchestrator.kt => AiHandler.kt} (97%) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiOrchestrator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt similarity index 97% rename from app/src/main/java/com/theveloper/pixelplay/data/ai/AiOrchestrator.kt rename to app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt index b3109247b..46cddb604 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiOrchestrator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt @@ -19,7 +19,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AiOrchestrator @Inject constructor( +class AiHandler @Inject constructor( private val preferencesRepo: AiPreferencesRepository, private val clientFactory: AiClientFactory, private val cacheDao: AiCacheDao, @@ -235,7 +235,7 @@ class AiOrchestrator @Inject constructor( ) ) }.onFailure { error -> - Timber.tag("AiOrchestrator").e(error, "Failed to persist AI usage") + Timber.tag("AiHandler").e(error, "Failed to persist AI usage") } } @@ -244,7 +244,7 @@ class AiOrchestrator @Inject constructor( } catch (e: Exception) { // AI Optimization: Robust failover logic—if one provider fails, we log and try the next in the chain val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(provider.displayName, e) - Timber.tag("AiOrchestrator").w(e, "Provider ${provider.name} failed: ${failure.message}") + Timber.tag("AiHandler").w(e, "Provider ${provider.name} failed: ${failure.message}") failedProviders.add("${provider.name}: ${failure.message ?: "Unknown error"}") // Trigger cooldown only on provider-level outages and account problems. if (failure.shouldCooldown()) { @@ -268,7 +268,7 @@ class AiOrchestrator @Inject constructor( "AI generation failed after trying ${failedProviders.size} providers:\n${failedProviders.joinToString("\n• ", prefix = "• ")}" } - Timber.tag("AiOrchestrator").e("All providers failed. Details: %s", failedProviders.joinToString(" | ")) + Timber.tag("AiHandler").e("All providers failed. Details: %s", failedProviders.joinToString(" | ")) throw Exception(errorMessage) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt index f67ccb324..eaa67258c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt @@ -17,7 +17,7 @@ data class SongMetadata( ) class AiMetadataGenerator @Inject constructor( - private val aiOrchestrator: AiOrchestrator, + private val aiHandler: AiHandler, private val json: Json ) { private fun cleanJson(jsonString: String): String { @@ -45,7 +45,7 @@ class AiMetadataGenerator @Inject constructor( """.trimIndent() - val responseText = aiOrchestrator.generateContent(fullPrompt, AiSystemPromptType.METADATA) + val responseText = aiHandler.generateContent(fullPrompt, AiSystemPromptType.METADATA) if (responseText.isBlank()) { Timber.e("AI returned an empty or null response.") return Result.failure(Exception("AI returned an empty response.")) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt index 7017ec6a6..3e2e5a524 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt @@ -11,7 +11,7 @@ import kotlin.math.max class AiPlaylistGenerator @Inject constructor( private val dailyMixManager: DailyMixManager, - private val aiOrchestrator: AiOrchestrator, + private val aiHandler: AiHandler, private val digestGenerator: UserProfileDigestGenerator, private val preferencesRepo: AiPreferencesRepository, private val json: Json @@ -73,7 +73,7 @@ class AiPlaylistGenerator @Inject constructor( """.trimIndent() - val responseText = aiOrchestrator.generateContent(fullPrompt, type) + val responseText = aiHandler.generateContent(fullPrompt, type) val songIds = extractPlaylistSongIds(responseText) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt index b6d943af8..c97959f5c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt @@ -15,7 +15,7 @@ data class GeminiModel( @Singleton class GeminiModelService @Inject constructor( - private val orchestrator: AiOrchestrator, + private val handler: AiHandler, private val digestGenerator: UserProfileDigestGenerator, private val musicRepository: MusicRepository, private val workerManager: AiWorkerManager @@ -122,7 +122,7 @@ class GeminiModelService @Inject constructor( digestGenerator.generateDigest(allSongs) } else "" - return orchestrator.generateContent( + return handler.generateContent( prompt = prompt, type = type, temperature = temperature, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt index d339efbba..a09389b4a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt @@ -41,7 +41,7 @@ class AiPreferencesRepository @Inject constructor( fun getSystemPrompt(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_system_prompt") } - // Generic accessors for AiOrchestrator + // Generic accessors for AiHandler fun getApiKey(provider: AiProvider): Flow = dataStore.data.map { preferences -> preferences[Keys.getApiKey(provider)]?.trim() ?: "" } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt index a19c57615..0b1441d97 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt @@ -7,7 +7,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.theveloper.pixelplay.data.ai.AiNotificationManager -import com.theveloper.pixelplay.data.ai.AiOrchestrator +import com.theveloper.pixelplay.data.ai.AiHandler import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.UserProfileDigestGenerator import com.theveloper.pixelplay.data.model.Song @@ -24,7 +24,7 @@ import timber.log.Timber class AiWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, - private val orchestrator: AiOrchestrator, + private val handler: AiHandler, private val notificationManager: AiNotificationManager, private val musicRepository: MusicRepository, private val digestGenerator: UserProfileDigestGenerator, @@ -75,7 +75,7 @@ class AiWorker @AssistedInject constructor( digestGenerator.generateDigest(allSongs, isSafe) } else "" - val result = orchestrator.generateContent( + val result = handler.generateContent( prompt = prompt, type = type, temperature = temp, 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 ac467232d..948a0ad27 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 @@ -35,7 +35,7 @@ class AiStateHolder @Inject constructor( private val playlistPreferencesRepository: PlaylistPreferencesRepository, private val dailyMixStateHolder: DailyMixStateHolder, private val notificationManager: AiNotificationManager, - private val aiOrchestrator: com.theveloper.pixelplay.data.ai.AiOrchestrator + private val aiHandler: com.theveloper.pixelplay.data.ai.AiHandler ) { // State // AI State Management: Observables for tracking background generation progress @@ -363,7 +363,7 @@ Lyrics to translate: $lyricsText """.trimIndent() - val response = aiOrchestrator.generateContent( + val response = aiHandler.generateContent( prompt = prompt, type = AiSystemPromptType.GENERAL, temperature = 0.1f From 71dd8fb850dd7a4082010bba32745b03302e99b4 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:00:30 +0545 Subject: [PATCH 02/30] feat(ai): add UnifiedModelFilter for consistent model filtering across all providers - Create UnifiedModelFilter utility that filters out embedding, image, TTS, speech, moderation, vision-only, and other non-chat models - Update GeminiAiClient to use UnifiedModelFilter instead of hardcoded markers - Update GenericOpenAiClient to use UnifiedModelFilter instead of inline filter --- .../data/ai/provider/GeminiAiClient.kt | 10 ++----- .../data/ai/provider/GenericOpenAiClient.kt | 4 +-- .../data/ai/provider/UnifiedModelFilter.kt | 30 +++++++++++++++++++ 3 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt index b730603cb..30d638b59 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt @@ -25,12 +25,7 @@ class GeminiAiClient(private val apiKey: String) : AiClient { private const val DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite" private const val BASE_URL = "https://generativelanguage.googleapis.com/v1beta" - // Markers for models that cannot perform text chat generation. These are the - // only things we filter out — every other model the API returns is selectable. - private val NON_CHAT_MARKERS = listOf( - "embedding", "aqa", "imagen", "image-generation", - "tts", "audio", "veo", "vision-only", "learnlm-embedding" - ) + } private val httpClient = OkHttpClient.Builder() @@ -260,8 +255,7 @@ class GeminiAiClient(private val apiKey: String) : AiClient { } private fun isNonChatModel(modelName: String): Boolean { - val lower = modelName.lowercase() - return NON_CHAT_MARKERS.any { lower.contains(it) } + return !UnifiedModelFilter.isModelUsableForChat(modelName) } private fun getDefaultModels(): List { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index 658906dd2..511a76328 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -140,9 +140,7 @@ class GenericOpenAiClient( val responseBody = response.body.string() val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id }.filter { - !it.contains("whisper") && !it.contains("embed") && !it.contains("tts") - } + modelsResponse.data.map { it.id }.let { UnifiedModelFilter.filterChatModels(it) } } catch (e: Exception) { listOf(defaultModelId) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt new file mode 100644 index 000000000..d72d1786f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt @@ -0,0 +1,30 @@ +package com.theveloper.pixelplay.data.ai.provider + +object UnifiedModelFilter { + private val UNSUITABLE_PATTERNS = listOf( + "embedding", "embed", "aqa", "imagen", "image-generation", + "tts", "text-to-speech", "speech", "audio", "whisper", + "veo", "vision-only", "learnlm-embedding", "moderation", + "dall-e", "stable-diffusion", "sdxl", "kandinsky", + "upscale", "background", "remove-background", + "segment", "detect", "classify", "object-detection" + ) + + fun isModelUsableForChat(modelName: String): Boolean { + val lower = modelName.lowercase() + return UNSUITABLE_PATTERNS.none { lower.contains(it) } + } + + fun filterChatModels(models: List): List { + return models.filter { isModelUsableForChat(it) } + } + + fun filterChatModelsWithDefaults( + apiModels: List, + defaultModels: List + ): List { + return (apiModels.filter { isModelUsableForChat(it) } + defaultModels) + .distinct() + .sorted() + } +} From d599acbe9434f33e9608748056980c2a404d006b Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:00:49 +0545 Subject: [PATCH 03/30] refactor(ai): unify DeepSeek/Groq/Mistral to GenericOpenAiClient These providers all use OpenAI-compatible APIs. Switching from dedicated client classes to GenericOpenAiClient eliminates duplicate code. The old class files are kept on disk but no longer referenced. --- .../data/ai/provider/AiClientFactory.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 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 a1c29211e..c7591f390 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 @@ -22,9 +22,24 @@ class AiClientFactory @Inject constructor() { return when (provider) { AiProvider.GEMINI -> GeminiAiClient(apiKey) - AiProvider.DEEPSEEK -> DeepSeekAiClient(apiKey) - AiProvider.GROQ -> GroqAiClient(apiKey) - AiProvider.MISTRAL -> MistralAiClient(apiKey) + AiProvider.DEEPSEEK -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.deepseek.com", + defaultModelId = "deepseek-chat", + providerName = "DeepSeek" + ) + AiProvider.GROQ -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.groq.com/openai/v1", + defaultModelId = "llama-3.1-8b-instant", + providerName = "Groq" + ) + AiProvider.MISTRAL -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.mistral.ai/v1", + defaultModelId = "mistral-large-latest", + providerName = "Mistral" + ) AiProvider.NVIDIA -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://integrate.api.nvidia.com/v1", From 5d08f1be71b20c065a78a109538fbfe988fbabb8 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:02:07 +0545 Subject: [PATCH 04/30] feat(ai): add CUSTOM provider entry to AiProvider enum Add CUSTOM provider with hasConfigurableUrl=true and requiresApiKey=true for user-configured self-hosted/custom API endpoints. --- .../com/theveloper/pixelplay/data/ai/provider/AiProvider.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 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 f0f7b91dd..adc08789b 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,7 +3,7 @@ package com.theveloper.pixelplay.data.ai.provider /** * Enum representing available AI providers */ -enum class AiProvider(val displayName: String, val requiresApiKey: Boolean) { +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), @@ -12,7 +12,8 @@ enum class AiProvider(val displayName: String, val requiresApiKey: Boolean) { KIMI("Kimi (Moonshot)", requiresApiKey = true), GLM("Zhipu GLM", requiresApiKey = true), OPENAI("OpenAI", requiresApiKey = true), - OPENROUTER("OpenRouter", requiresApiKey = true); + OPENROUTER("OpenRouter", requiresApiKey = true), + CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true); companion object { fun fromString(value: String): AiProvider { From b1f3c80417d3522c8ffd905e68b2a94a4461f920 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:02:12 +0545 Subject: [PATCH 05/30] feat(ai): add CUSTOM provider and createClientWithUrl to AiClientFactory CUSTOM provider uses GenericOpenAiClient with an empty default URL (user configures it via settings). createClientWithUrl allows creating a client with a custom base URL for configurable-URL providers. --- .../pixelplay/data/ai/provider/AiClientFactory.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 c7591f390..1e4190050 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 @@ -70,6 +70,17 @@ class AiClientFactory @Inject constructor() { defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free", providerName = "OpenRouter" ) + AiProvider.CUSTOM -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "", + defaultModelId = "", + providerName = "Custom Provider" + ) } } + + fun createClientWithUrl(provider: AiProvider, apiKey: String, baseUrl: String): AiClient { + val displayName = provider.displayName + return GenericOpenAiClient(apiKey, baseUrl.trimEnd('/'), "", displayName) + } } From a5fdfeff616c679082208c1060ca7ee4cee61560 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:02:17 +0545 Subject: [PATCH 06/30] feat(ai): add CUSTOM to provider fallback chain in AiProviderSupport --- .../theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt index 386758356..7a8d8de2c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt @@ -88,7 +88,8 @@ internal object AiProviderSupport { AiProvider.OPENROUTER, AiProvider.NVIDIA, AiProvider.KIMI, - AiProvider.GLM + AiProvider.GLM, + AiProvider.CUSTOM ) return buildList { From cf7f4b50521a09cff38982ab24a1f251b2a245e2 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:02:23 +0545 Subject: [PATCH 07/30] feat(ai): add base URL support and CUSTOM provider prefs to AiPreferencesRepository Add getBaseUrl/setBaseUrl generic accessors for configurable-URL providers. Add customApiKey, customModel, customSystemPrompt, customBaseUrl convenience flows for the CUSTOM provider. --- .../data/preferences/AiPreferencesRepository.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt index a09389b4a..ad1cdc5e6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt @@ -39,6 +39,7 @@ class AiPreferencesRepository @Inject constructor( fun getApiKey(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_api_key") fun getModel(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_model") fun getSystemPrompt(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_system_prompt") + fun getBaseUrl(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_base_url") } // Generic accessors for AiHandler @@ -53,6 +54,9 @@ class AiPreferencesRepository @Inject constructor( preferences[Keys.getSystemPrompt(provider)] ?: DEFAULT_SYSTEM_PROMPT } + fun getBaseUrl(provider: AiProvider): Flow = + dataStore.data.map { preferences -> preferences[Keys.getBaseUrl(provider)] ?: "" } + suspend fun setApiKey(provider: AiProvider, apiKey: String) { dataStore.edit { preferences -> preferences[Keys.getApiKey(provider)] = apiKey.trim() } } @@ -71,6 +75,10 @@ class AiPreferencesRepository @Inject constructor( } } + suspend fun setBaseUrl(provider: AiProvider, url: String) { + dataStore.edit { preferences -> preferences[Keys.getBaseUrl(provider)] = url.trim() } + } + // Convenience properties for legacy compatibility (e.g. PlayerViewModel) val geminiApiKey: Flow = getApiKey(AiProvider.GEMINI) val geminiModel: Flow = getModel(AiProvider.GEMINI) @@ -108,6 +116,11 @@ class AiPreferencesRepository @Inject constructor( val openrouterModel: Flow = getModel(AiProvider.OPENROUTER) val openrouterSystemPrompt: Flow = getSystemPrompt(AiProvider.OPENROUTER) + val customApiKey: Flow = getApiKey(AiProvider.CUSTOM) + val customModel: Flow = getModel(AiProvider.CUSTOM) + val customSystemPrompt: Flow = getSystemPrompt(AiProvider.CUSTOM) + val customBaseUrl: Flow = getBaseUrl(AiProvider.CUSTOM) + val aiProvider: Flow = dataStore.data.map { preferences -> preferences[Keys.AI_PROVIDER] ?: "GEMINI" } From c8fd759ec41b6915b3db7d5bdb20a4d369662183 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:02:34 +0545 Subject: [PATCH 08/30] feat(ai): add OLLAMA provider entry to AiProvider enum Ollama is a cloud API-based provider (requires API key, fixed URL), separate from the CUSTOM provider which allows custom endpoints. --- .../java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt | 1 + 1 file changed, 1 insertion(+) 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 adc08789b..229f1d314 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 @@ -13,6 +13,7 @@ enum class AiProvider(val displayName: String, val requiresApiKey: Boolean, val GLM("Zhipu GLM", requiresApiKey = true), OPENAI("OpenAI", requiresApiKey = true), OPENROUTER("OpenRouter", requiresApiKey = true), + OLLAMA("Ollama", requiresApiKey = true), CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true); companion object { From c83cc4f673dbe1a75bdb7e8c787e5db28829419c Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:02:55 +0545 Subject: [PATCH 09/30] feat(ai): add OLLAMA provider implementation to AiClientFactory --- .../pixelplay/data/ai/provider/AiClientFactory.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 1e4190050..4322ac24e 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 @@ -70,6 +70,12 @@ class AiClientFactory @Inject constructor() { defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free", providerName = "OpenRouter" ) + AiProvider.OLLAMA -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.ollama.ai/v1", + defaultModelId = "llama3", + providerName = "Ollama" + ) AiProvider.CUSTOM -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "", From f13e7dabc0cc35001c3c2436709a793c23df4989 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:03:23 +0545 Subject: [PATCH 10/30] feat(ai): add OLLAMA to provider fallback chain --- .../theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt index 7a8d8de2c..82c61f6a9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt @@ -89,6 +89,7 @@ internal object AiProviderSupport { AiProvider.NVIDIA, AiProvider.KIMI, AiProvider.GLM, + AiProvider.OLLAMA, AiProvider.CUSTOM ) From 452dcfe928cc00d75c8d0f1fc561dfda3705e747 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:03:37 +0545 Subject: [PATCH 11/30] feat(ai): add OLLAMA provider convenience flows to AiPreferencesRepository --- .../pixelplay/data/preferences/AiPreferencesRepository.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt index ad1cdc5e6..f30843e51 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt @@ -116,6 +116,10 @@ class AiPreferencesRepository @Inject constructor( val openrouterModel: Flow = getModel(AiProvider.OPENROUTER) val openrouterSystemPrompt: Flow = getSystemPrompt(AiProvider.OPENROUTER) + val ollamaApiKey: Flow = getApiKey(AiProvider.OLLAMA) + val ollamaModel: Flow = getModel(AiProvider.OLLAMA) + val ollamaSystemPrompt: Flow = getSystemPrompt(AiProvider.OLLAMA) + val customApiKey: Flow = getApiKey(AiProvider.CUSTOM) val customModel: Flow = getModel(AiProvider.CUSTOM) val customSystemPrompt: Flow = getSystemPrompt(AiProvider.CUSTOM) From 3abd56bd565d5eab5f0b09ba130b17bd6a10a07f Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:04:03 +0545 Subject: [PATCH 12/30] feat(ai): add SearchableModelSelector composable with search bar A new composable that opens a ModalBottomSheet with a searchable LazyColumn of AI models. Includes search filtering, model count display, and visual selection state. --- .../screens/SettingsComponents.kt | 177 +++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) 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 b13cad46c..3db71c961 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 @@ -362,14 +362,187 @@ fun ExpressiveSettingsGroup( ) { Column( modifier = modifier - .clip(RoundedCornerShape(24.dp)) // Large corners for the group + .clip(RoundedCornerShape(24.dp)) .background(Color.Transparent), - //verticalArrangement = Arrangement.spacedBy(4.dp) ) { content() } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchableModelSelector( + label: String, + description: String, + models: List, + selectedModelName: String, + onModelSelected: (String) -> Unit, + leadingIcon: @Composable () -> Unit +) { + var showSheet by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + val selectedDisplayName = models.find { it.name == selectedModelName }?.displayName ?: selectedModelName + + 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 models...") }, + 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 filteredModels = remember(models, searchQuery) { + if (searchQuery.isBlank()) models + else models.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.displayName.contains(searchQuery, ignoreCase = true) + } + } + + Text( + text = "${filteredModels.size} model${if (filteredModels.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(filteredModels, key = { it.name }) { model -> + val isSelected = model.name == selectedModelName + Surface( + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + onModelSelected(model.name) + showSheet = false + searchQuery = "" + } + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + ) + Text( + text = model.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + } +} + @Composable fun SliderSettingsItem( label: String, From 775766de375412890579d325cdefc72400f73020 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:05:34 +0545 Subject: [PATCH 13/30] feat(ai): add Ollama/Custom provider flows and base URL state to SettingsViewModel --- .../viewmodel/SettingsViewModel.kt | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) 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 d07d8b3df..41f4a5e01 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 @@ -275,6 +275,30 @@ class SettingsViewModel @Inject constructor( val openrouterSystemPrompt: StateFlow = aiPreferencesRepository.openrouterSystemPrompt .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_OPENROUTER_SYSTEM_PROMPT) + val ollamaApiKey: StateFlow = aiPreferencesRepository.ollamaApiKey + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val ollamaModel: StateFlow = aiPreferencesRepository.ollamaModel + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val ollamaSystemPrompt: StateFlow = aiPreferencesRepository.ollamaSystemPrompt + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) + + val customApiKey: StateFlow = aiPreferencesRepository.customApiKey + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customModel: StateFlow = aiPreferencesRepository.customModel + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customSystemPrompt: StateFlow = aiPreferencesRepository.customSystemPrompt + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) + val customBaseUrl: StateFlow = aiPreferencesRepository.customBaseUrl + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + val currentAiBaseUrl: StateFlow = aiProvider + .flatMapLatest { provider -> + val p = AiProvider.fromString(provider) + if (p.hasConfigurableUrl) aiPreferencesRepository.getBaseUrl(p) + else flowOf("") + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + fun onAiApiKeyChange(apiKey: String) { viewModelScope.launch { val providerStr = aiProvider.value @@ -349,6 +373,25 @@ class SettingsViewModel @Inject constructor( 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) + } + } fun onAiModelChange(model: String) { viewModelScope.launch { @@ -366,6 +409,8 @@ class SettingsViewModel @Inject constructor( fun onGlmModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GLM, model) } fun onOpenAiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OPENAI, model) } fun onOpenrouterModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OPENROUTER, model) } + fun onOllamaModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OLLAMA, model) } + fun onCustomModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.CUSTOM, model) } fun onAiSystemPromptChange(prompt: String) { viewModelScope.launch { @@ -383,6 +428,8 @@ class SettingsViewModel @Inject constructor( fun onGlmSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GLM, prompt) } fun onOpenAiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OPENAI, prompt) } fun onOpenrouterSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OPENROUTER, prompt) } + fun onOllamaSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OLLAMA, prompt) } + fun onCustomSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.CUSTOM, prompt) } fun resetAiSystemPrompt() { viewModelScope.launch { @@ -400,6 +447,8 @@ class SettingsViewModel @Inject constructor( fun resetGlmSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GLM) } fun resetOpenAiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OPENAI) } fun resetOpenrouterSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OPENROUTER) } + fun resetOllamaSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OLLAMA) } + fun resetCustomSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.CUSTOM) } fun clearAiUsageData() { viewModelScope.launch { @@ -1149,7 +1198,13 @@ class SettingsViewModel @Inject constructor( val models = if (provider == AiProvider.GEMINI) { geminiModelService.fetchAvailableModels(apiKey).getOrThrow() } else { - val aiClient = aiClientFactory.createClient(provider, apiKey) + val baseUrl = if (provider.hasConfigurableUrl) + aiPreferencesRepository.getBaseUrl(provider).first() + else "" + val aiClient = if (provider.hasConfigurableUrl) + aiClientFactory.createClientWithUrl(provider, apiKey, baseUrl) + else + aiClientFactory.createClient(provider, apiKey) aiClient.getAvailableModels(apiKey) .map { it.trim() } .filter { it.isNotBlank() } From ee57fae98a9adbfa69078d51c11a913e8d4105d8 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:07:02 +0545 Subject: [PATCH 14/30] feat(ui): add OLLAMA/CUSTOM provider labels, SearchableModelSelector, and base URL config field --- .../screens/SettingsCategoryScreen.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 d1ff2628b..379d9c11d 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 @@ -950,6 +950,8 @@ fun SettingsCategoryScreen( com.theveloper.pixelplay.data.ai.provider.AiProvider.GLM -> stringResource(R.string.settings_ai_source_glm) com.theveloper.pixelplay.data.ai.provider.AiProvider.OPENAI -> stringResource(R.string.settings_ai_source_openai) com.theveloper.pixelplay.data.ai.provider.AiProvider.OPENROUTER -> "OpenRouter (openrouter.ai)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.OLLAMA -> "Ollama (cloud)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.CUSTOM -> "Custom Provider" } AiApiKeyItem( @@ -999,18 +1001,30 @@ fun SettingsCategoryScreen( ) } } else if (uiState.availableModels.isNotEmpty()) { - ThemeSelectorItem( + SearchableModelSelector( label = stringResource(R.string.settings_ai_model_title), description = stringResource(R.string.settings_ai_model_subtitle), - options = uiState.availableModels.associate { it.name to it.displayName }, - selectedKey = currentAiModel.ifEmpty { uiState.availableModels.firstOrNull()?.name ?: "" }, - onSelectionChanged = { settingsViewModel.onAiModelChange(it) }, + models = uiState.availableModels, + selectedModelName = currentAiModel.ifEmpty { uiState.availableModels.firstOrNull()?.name ?: "" }, + onModelSelected = { settingsViewModel.onAiModelChange(it) }, leadingIcon = { Icon(Icons.Rounded.Science, null, tint = MaterialTheme.colorScheme.secondary) } ) } } } + // Base URL Section (only for configurable URL providers) + if (provider.hasConfigurableUrl) { + SettingsSubsection(title = "API Base URL") { + AiApiKeyItem( + apiKey = customBaseUrl, + onApiKeySave = { settingsViewModel.onCustomBaseUrlChange(it) }, + title = "Base URL", + subtitle = "e.g. https://api.example.com/v1" + ) + } + } + // Prompt Behavior Section SettingsSubsection( title = stringResource(R.string.settings_prompt_behavior_section), From ca27d419aaf73be7359c42f689e57db8e5175218 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:07:13 +0545 Subject: [PATCH 15/30] chore: remove unused DeepSeekAiClient, GroqAiClient, MistralAiClient (unified into GenericOpenAiClient) --- .../data/ai/provider/DeepSeekAiClient.kt | 171 ------------------ .../data/ai/provider/GroqAiClient.kt | 170 ----------------- .../data/ai/provider/MistralAiClient.kt | 169 ----------------- 3 files changed, 510 deletions(-) delete mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt delete mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt delete mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt deleted file mode 100644 index afb84b3ea..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.theveloper.pixelplay.data.ai.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -/** - * DeepSeek AI provider implementation - * Uses OpenAI-compatible API - */ -class DeepSeekAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_DEEPSEEK_MODEL = "deepseek-chat" - private const val BASE_URL = "https://api.deepseek.com" - } - - @Serializable - data class ChatMessage(val role: String, val content: String) - - @Serializable - data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - data class ChatChoice(val message: ChatMessage) - - @Serializable - data class ChatResponse(val choices: List) - - @Serializable - data class ModelItem(val id: String) - - @Serializable - data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_DEEPSEEK_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "DeepSeek", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "DeepSeek", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("DeepSeek", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // DeepSeek estimation - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_DEEPSEEK_MODEL - - private fun getDefaultModels(): List { - return listOf( - "deepseek-chat", - "deepseek-reasoner" - ) - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt deleted file mode 100644 index 0adf6cf70..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.theveloper.pixelplay.data.ai.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -class GroqAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_MODEL = "llama-3.1-8b-instant" - private const val BASE_URL = "https://api.groq.com/openai/v1" - } - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "Groq", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "Groq", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Groq", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Groq doesn't provide a native token counting endpoint, so we estimate. - // Rule of thumb: 1 token ≈ 4 characters for English text. - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id }.filter { !it.contains("whisper") } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_MODEL - - private fun getDefaultModels(): List { - return listOf( - "llama-3.1-8b-instant", - "llama-3.3-70b-versatile", - "mixtral-8x7b-32768", - "gemma2-9b-it" - ) - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt deleted file mode 100644 index a4d166e2a..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.theveloper.pixelplay.data.ai.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -class MistralAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_MODEL = "mistral-large-latest" - private const val BASE_URL = "https://api.mistral.ai/v1" - } - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "Mistral", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "Mistral", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Mistral", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Mistral estimation - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_MODEL - - private fun getDefaultModels(): List { - return listOf( - "mistral-large-latest", - "mistral-small-latest", - "open-mixtral-8x22b", - "open-mixtral-8x7b" - ) - } -} From bd711b5cac022f7c117f3ba7038311480e35d63a Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:11:31 +0545 Subject: [PATCH 16/30] feat(ai): add topP, topK, maxTokens, presencePenalty, frequencyPenalty to AiClient interface --- .../pixelplay/data/ai/provider/AiClient.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt index 413a0fb2f..348547ee6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt @@ -5,20 +5,17 @@ package com.theveloper.pixelplay.data.ai.provider * Defines common operations for text generation and metadata completion */ interface AiClient { - - /** - * Generate text content based on a prompt - * @param model The model identifier to use - * @param systemPrompt The system prompt instructions - * @param prompt The input prompt - * @param temperature Creativity control (0.0 to 1.0) - * @return Generated text response - */ + suspend fun generateContent( model: String, systemPrompt: String, prompt: String, - temperature: Float = 0.7f + temperature: Float = 0.7f, + topP: Float = 0.95f, + topK: Int = 64, + maxTokens: Int = 4096, + presencePenalty: Float = 0.0f, + frequencyPenalty: Float = 0.0f ): String /** From 70c756bd2a0e64e7ae979b6b3f3f90072b2b9815 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:12:04 +0545 Subject: [PATCH 17/30] feat(ai): add topP, maxTokens, presencePenalty, frequencyPenalty to GenericOpenAiClient ChatRequest --- .../data/ai/provider/GenericOpenAiClient.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index 511a76328..fd0fb1c1d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -2,6 +2,7 @@ package com.theveloper.pixelplay.data.ai.provider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -27,7 +28,11 @@ class GenericOpenAiClient( private data class ChatRequest( val model: String, val messages: List, - val temperature: Double = 0.7 + val temperature: Double = 0.7, + @SerialName("top_p") val topP: Double? = null, + @SerialName("max_tokens") val maxTokens: Int? = null, + @SerialName("presence_penalty") val presencePenalty: Double? = null, + @SerialName("frequency_penalty") val frequencyPenalty: Double? = null ) @Serializable @@ -57,7 +62,12 @@ class GenericOpenAiClient( model: String, systemPrompt: String, prompt: String, - temperature: Float + temperature: Float, + topP: Float, + topK: Int, + maxTokens: Int, + presencePenalty: Float, + frequencyPenalty: Float ): String { return withContext(Dispatchers.IO) { val resolvedModel = model.ifBlank { defaultModelId } @@ -70,7 +80,11 @@ class GenericOpenAiClient( val requestBody = ChatRequest( model = resolvedModel, messages = messagesList, - temperature = temperature.toDouble() + temperature = temperature.toDouble(), + topP = topP.toDouble(), + maxTokens = maxTokens.takeIf { it > 0 }, + presencePenalty = presencePenalty.toDouble(), + frequencyPenalty = frequencyPenalty.toDouble() ) val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) From 479104fdf468d59bc65380b7f5b62c16cbc3b92a Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:12:29 +0545 Subject: [PATCH 18/30] feat(ai): add topP, topK, maxTokens, presencePenalty, frequencyPenalty to GeminiAiClient --- .../data/ai/provider/GeminiAiClient.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt index 30d638b59..c2288296a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt @@ -50,7 +50,10 @@ class GeminiAiClient(private val apiKey: String) : AiClient { private data class GenerationConfig( val temperature: Double, val topK: Int = 64, - val topP: Double = 0.95 + val topP: Double = 0.95, + @SerialName("maxOutputTokens") val maxOutputTokens: Int = 8192, + @SerialName("presencePenalty") val presencePenalty: Double? = null, + @SerialName("frequencyPenalty") val frequencyPenalty: Double? = null ) @Serializable @@ -81,7 +84,12 @@ class GeminiAiClient(private val apiKey: String) : AiClient { model: String, systemPrompt: String, prompt: String, - temperature: Float + temperature: Float, + topP: Float, + topK: Int, + maxTokens: Int, + presencePenalty: Float, + frequencyPenalty: Float ): String { return withContext(Dispatchers.IO) { val resolvedModel = model.ifBlank { DEFAULT_GEMINI_MODEL } @@ -91,7 +99,14 @@ class GeminiAiClient(private val apiKey: String) : AiClient { systemInstruction = systemPrompt .takeIf { it.isNotBlank() } ?.let { Content(parts = listOf(Part(it))) }, - generationConfig = GenerationConfig(temperature = temperature.toDouble()) + generationConfig = GenerationConfig( + temperature = temperature.toDouble(), + topK = topK, + topP = topP.toDouble(), + maxOutputTokens = maxTokens, + presencePenalty = presencePenalty.toDouble().takeIf { it != 0.0 }, + frequencyPenalty = frequencyPenalty.toDouble().takeIf { it != 0.0 } + ) ) val jsonBody = json.encodeToString(GenerateRequest.serializer(), requestBody) From 9ddb02b3a7137655a4cc9e6afda3d1cab2af58d0 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:13:05 +0545 Subject: [PATCH 19/30] feat(ai): add generation parameter and song data configuration preferences Adds DataStore-backed preferences for temperature, topP, topK, maxTokens, presencePenalty, frequencyPenalty, sample size, digest mode (safe/full), and extended fields toggle. --- .../preferences/AiPreferencesRepository.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt index f30843e51..964875d04 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt @@ -4,6 +4,8 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -35,6 +37,15 @@ class AiPreferencesRepository @Inject constructor( private object Keys { val AI_PROVIDER = stringPreferencesKey("ai_provider") val SAFE_TOKEN_LIMIT = booleanPreferencesKey("safe_token_limit") + val AI_TEMPERATURE = floatPreferencesKey("ai_temperature") + val AI_TOP_P = floatPreferencesKey("ai_top_p") + val AI_TOP_K = intPreferencesKey("ai_top_k") + val AI_MAX_TOKENS = intPreferencesKey("ai_max_tokens") + val AI_PRESENCE_PENALTY = floatPreferencesKey("ai_presence_penalty") + val AI_FREQUENCY_PENALTY = floatPreferencesKey("ai_frequency_penalty") + val AI_SAMPLE_SIZE = intPreferencesKey("ai_sample_size") + val AI_DIGEST_MODE = stringPreferencesKey("ai_digest_mode") + val AI_INCLUDE_EXTENDED_FIELDS = booleanPreferencesKey("ai_include_extended_fields") fun getApiKey(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_api_key") fun getModel(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_model") @@ -131,6 +142,33 @@ class AiPreferencesRepository @Inject constructor( val isSafeTokenLimitEnabled: Flow = dataStore.data.map { preferences -> preferences[Keys.SAFE_TOKEN_LIMIT] ?: true } + val aiTemperature: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_TEMPERATURE] ?: 0.7f } + + val aiTopP: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_TOP_P] ?: 0.95f } + + val aiTopK: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_TOP_K] ?: 64 } + + val aiMaxTokens: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_MAX_TOKENS] ?: 4096 } + + val aiPresencePenalty: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_PRESENCE_PENALTY] ?: 0.0f } + + val aiFrequencyPenalty: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_FREQUENCY_PENALTY] ?: 0.0f } + + val aiSampleSize: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_SAMPLE_SIZE] ?: 40 } + + val aiDigestMode: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_DIGEST_MODE] ?: "safe" } + + val aiIncludeExtendedFields: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_INCLUDE_EXTENDED_FIELDS] ?: false } + suspend fun setAiProvider(provider: String) { dataStore.edit { preferences -> preferences[Keys.AI_PROVIDER] = provider } } @@ -138,4 +176,40 @@ class AiPreferencesRepository @Inject constructor( suspend fun setSafeTokenLimitEnabled(enabled: Boolean) { dataStore.edit { preferences -> preferences[Keys.SAFE_TOKEN_LIMIT] = enabled } } + + suspend fun setAiTemperature(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_TEMPERATURE] = value } + } + + suspend fun setAiTopP(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_TOP_P] = value } + } + + suspend fun setAiTopK(value: Int) { + dataStore.edit { preferences -> preferences[Keys.AI_TOP_K] = value } + } + + suspend fun setAiMaxTokens(value: Int) { + dataStore.edit { preferences -> preferences[Keys.AI_MAX_TOKENS] = value } + } + + suspend fun setAiPresencePenalty(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_PRESENCE_PENALTY] = value } + } + + suspend fun setAiFrequencyPenalty(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_FREQUENCY_PENALTY] = value } + } + + suspend fun setAiSampleSize(value: Int) { + dataStore.edit { preferences -> preferences[Keys.AI_SAMPLE_SIZE] = value } + } + + suspend fun setAiDigestMode(mode: String) { + dataStore.edit { preferences -> preferences[Keys.AI_DIGEST_MODE] = mode } + } + + suspend fun setAiIncludeExtendedFields(enabled: Boolean) { + dataStore.edit { preferences -> preferences[Keys.AI_INCLUDE_EXTENDED_FIELDS] = enabled } + } } From fa1ccadbee45b4d2d12761558c3b05dc059d3bb3 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:13:51 +0545 Subject: [PATCH 20/30] feat(ai): overhaul AiSystemPromptEngine with chain-of-thought, few-shot examples, and quality guide rails --- .../pixelplay/data/ai/AiSystemPromptEngine.kt | 176 +++++++++++++----- 1 file changed, 134 insertions(+), 42 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 6759713d0..9edf9e404 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 @@ -17,85 +17,177 @@ enum class AiSystemPromptType { @Singleton class AiSystemPromptEngine @Inject constructor() { - // Advanced prompt engineering: Enforcing structured output boundaries private val UNIVERSAL_CONSTRAINTS = """ - + - You are communicating with a programmatic parser, not a human. - - Output ONLY the expected structure. - - NO markdown formatting (e.g., do not wrap in ```json). - - NO conversational filler, greetings, or explanations. - - Any deviation will crash the application. - + - Output ONLY the expected structure — nothing else. + - NO markdown fences, NO code blocks, NO conversational framing. + - Any deviation will cause an application crash. + - If uncertain, make your best reasoned guess rather than refusing. + - Verify your output matches the required schema before responding. + + """.trimIndent() + + private val playlistFewShot = """ + + GOOD: ["a1b2c3","d4e5f6","g7h8i9"] + BAD: Here is a playlist for you: ["a1b2c3","d4e5f6"] + GOOD IDs are exactly 6 alphanumeric characters from the pool. + Every ID in your output MUST exist in the candidate_pool. + + """.trimIndent() + + private val metadataFewShot = """ + + Input: title="Thriller (2008 Remaster)", artist="Micheal Jakson", album="THRILLER 25", genre="Pop" + Output: {"title":"Thriller (2008 Remaster)","artist":"Michael Jackson","album":"Thriller 25","genre":"Pop"} + + Input: title="untitled", artist="unknown", album="", genre="Electronic" + Output: {"title":"Untitled","artist":"Unknown Artist","album":"","genre":"Synthwave"} + + Input: title="Bohemian Rhapsody", artist="Queen", album="A Night at the Opera", genre="Rock" + Output: {"title":"Bohemian Rhapsody","artist":"Queen","album":"A Night at the Opera","genre":"Progressive Rock"} + + """.trimIndent() + + private val taggingFewShot = """ + + Input: synth-heavy track with driving bass and ethereal female vocals + Output: electronic, synth-driven, ethereal-vocals, driving-bass, atmospheric, hypnotic + + Input: acoustic guitar ballad with soft percussion and strings + Output: acoustic, fingerstyle-guitar, soft-percussion, string-arrangement, intimate, folk-tinged + + """.trimIndent() + + private val moodAnalysisFewShot = """ + + Input: Fast tempo (140 BPM), heavy distortion, aggressive drums, minor key + Output: Aggressive | Energy:0.95 | Valence:0.2 | Danceability:0.6 | Acousticness:0.0 + + Input: Slow tempo (70 BPM), acoustic piano, soft strings, major key + Output: Calm | Energy:0.2 | Valence:0.8 | Danceability:0.3 | Acousticness:0.9 + + """.trimIndent() + + private val dailyMixPersonaPrompt = """ + + - Open with a thematic hook that frames the mix (e.g., "This set leans into your late-night exploratory side.") + - Reference 1-2 specific listening patterns from the user's data to show curation intent. + - Describe the emotional arc of the mix in 2-3 sentences. + - Close with a subtle invitation to explore further. + - Tone: warm, insightful, never overly familiar or robotic. + - Length: 4-6 sentences maximum. + """.trimIndent() fun buildPrompt(basePersona: String, type: AiSystemPromptType, context: String = ""): String { val requirementLayer = when (type) { - AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> """ - Music curation engine mapping user requests to a strict candidate pool. + AiSystemPromptType.PLAYLIST -> """ + Expert music curator — you select songs from the provided pool to build cohesive, emotionally intelligent playlists. + + + 1. Parse the user's request for desired mood, energy, genre, era, or activity. + 2. Review the candidate pool — note available genres, tempos, and artists. + 3. Select songs that form a coherent arc: opening, build, peak, cool-down. + 4. Ensure variety — avoid repeating the same artist or genre consecutively. + 5. Prefer higher-scored songs (score field) but prioritize diversity and fit. + + - If request implies discovery/novelty, favor the [DISCOVERY_POOL] entries. + - If request implies familiarity/favorites, weight the [LISTENED] pool. + - For mixed/blended requests, interleave both pools for surprise + comfort. + - Target length is specified in the request — respect it within ±2 tracks. + + + Return ONLY a raw JSON array of song IDs. + Format: ["id_1","id_2","id_3",...,"id_N"] + + $playlistFewShot + """.trimIndent() + + AiSystemPromptType.DAILY_MIX -> """ + Daily Mix curator — you build themed mini-sets from the user's library for daily listening. - - If request implies "discovery/new", prioritize the [DISCOVERY_POOL]. - - If request implies "favorites/familiar", heavily weight the [LISTENED] pool. - - Otherwise, blend pools intelligently based on requested tempo, genre, or mood. - - Guarantee a cohesive listening journey with natural transitions. + + 1. Identify the dominant mood or genre from the user's recent listening profile. + 2. Select 8-15 tracks that form a single coherent mood/genre pocket. + 3. Lead with a familiar track, introduce 1-2 discoveries mid-set, close on a strong note. + + - Seamless transitions: adjacent tracks should share tempo (±20 BPM) or complementary keys. + - These mixes are for daily refreshes — avoid repeating the same tracks across mixes. - Return ONLY a raw JSON array of song IDs representing the playlist sequence. - Format: ["id_1","id_2","id_3"] + Return ONLY a raw JSON array of song IDs. + Format: ["id_1","id_2","id_3",...,"id_N"] """.trimIndent() AiSystemPromptType.METADATA -> """ - Precision music metadata specialist. + Precision music metadata specialist — you clean and enrich song metadata. - - Fix spelling errors and standardizations in song titles and artists. - - Replace generic genres ("Music", "Electronic") with highly specific subgenres ("Synthwave", "Nu-Disco"). + - Fix spelling errors (e.g., "Micheal" → "Michael", "Thriler" → "Thriller"). + - Capitalize properly: title case for titles and artists, proper casing for albums. + - Replace generic genres ("Music", "Electronic", "Other") with specific subgenres calibrated to the track's sound. + - If a field is empty or "unknown", leave it as empty string — do not fabricate data. + - Preserve any edition/remaster/year annotations in parentheses. - Return ONLY a raw JSON object string. - Format: {"title":"Clean Title", "artist":"Primary Artist", "album":"Album Name", "genre":"Specific Genre"} + Return ONLY a raw JSON object with EXACTLY these keys: + {"title":"...", "artist":"...", "album":"...", "genre":"..."} + $metadataFewShot """.trimIndent() AiSystemPromptType.TAGGING -> """ - Atmospheric audio tagging engine. + Atmospheric audio tagging engine — you generate perceptive acoustic tags for music discovery. - - Generate exactly 6-10 highly descriptive, hyphenated acoustic tags. - - Focus on mood, instrumentation, pace, and sonic texture. - - All tags must be strictly lowercase. + - Generate 6-10 hyphenated tags that capture: mood, instrumentation, tempo feel, sonic texture, and energy. + - All tags must be lowercase, hyphenated, and ordered by prominence. + - Be specific: prefer "lush-orchestral" over "orchestral", "glitchy-beats" over "beats". + - Tags should be useful for content-based recommendation — focus on audible characteristics. - Return ONLY a raw comma-separated text list. - Format: cinematic, atmospheric-build, dark-synth, driving-beat + Return ONLY a comma-separated list — no JSON, no formatting. + Format: tag1, tag2, tag3, tag4, tag5, tag6 + $taggingFewShot """.trimIndent() AiSystemPromptType.MOOD_ANALYSIS -> """ - Algorithmic audio sentiment analyzer. + Algorithmic audio sentiment analyzer — you infer emotional and structural attributes from track metadata. - - Deduce structural properties from the given metadata. - - Map confidence values from 0.0 to 1.0. - - Primary moods: Joyful, Aggressive, Calm, Melancholic, Radiant, Intense, Somber. + - Infer mood from: title keywords, genre, artist style, and any available context. + - Choose the single best PrimaryMood from: Joyful, Aggressive, Calm, Melancholic, Radiant, Intense, Somber, Euphoric, Brooding, Playful. + - Map confidence values 0.0-1.0 for each attribute based on how strongly the metadata supports it. + - Energy: driven by tempo indicators (fast/hard = high, slow/soft = low). + - Valence: positive/happy feel vs. negative/sad feel. + - Danceability: rhythmic groove suitability. + - Acousticness: likelihood of organic/non-electronic instrumentation. - Return ONLY the exact structured text format. - Format: PrimaryMood | Energy:0.9 | Valence:0.1 | Danceability:0.4 | Acousticness:0.0 + Return ONLY one line in this exact format: + PrimaryMood | Energy:0.X | Valence:0.X | Danceability:0.X | Acousticness:0.X + $moodAnalysisFewShot """.trimIndent() AiSystemPromptType.PERSONA -> """ - Daily Mix professional curator. You represent the persona: "$basePersona" + Daily Mix professional curator. You embody the persona: "$basePersona" - - Speak directly to the listener's tastes using their data. + - Speak directly to the listener using "you" and their data as evidence of your curation. - Maintain an enigmatic, sophisticated, and deeply empathetic tone. - - Keep responses reasonably concise but beautifully written. - - Do NOT use the universal programmatic constraints for persona responses; you are allowed to be conversational. + - Do NOT mention that you are an AI, a model, or that the data comes from a profile. + - Be concise but evocative — 4-6 sentences that feel hand-crafted. + $dailyMixPersonaPrompt """.trimIndent() AiSystemPromptType.GENERAL -> """ - PixelPlayer Assistant + PixelPlayer Assistant — a knowledgeable music companion. - Assist the user with any complex queries or actions inside their music ecosystem. + - Answer questions about music, artists, genres, and playback features. + - Be concise and accurate. If you don't know something, say so directly. + - Provide actionable answers that help the user enjoy their music library. """.trimIndent() } @@ -106,8 +198,9 @@ class AiSystemPromptEngine @Inject constructor() { $context - LISTENED Format: id|play_count|duration_mins|is_fav|metadata - DISCOVERY Format: unplayed candidate tracks + LISTENED Format: id|play_count|duration_mins|is_fav|title-artist + DISCOVERY Format: unplayed candidate tracks from the user's library + SCORE: internal relevance score (higher = better match) """.trimIndent() } else "" @@ -119,8 +212,7 @@ class AiSystemPromptEngine @Inject constructor() { """.trimIndent() - // Persona generation bypasses the strict JSON/raw constraints since it is meant to read as prose to the user - return if (type == AiSystemPromptType.PERSONA || type == AiSystemPromptType.GENERAL) { + return if (type == AiSystemPromptType.PERSONA) { listOf(systemBlock, contextLayer).filter { it.isNotBlank() }.joinToString("\n\n") } else { listOf(systemBlock, UNIVERSAL_CONSTRAINTS, contextLayer).filter { it.isNotBlank() }.joinToString("\n\n") From 19df14fa5649299edc596ddf1cdcf974d176d667 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:14:33 +0545 Subject: [PATCH 21/30] feat(ai): fetch and pass generation parameters from preferences in AiHandler --- .../theveloper/pixelplay/data/ai/AiHandler.kt | 89 ++++++++++++------- 1 file changed, 59 insertions(+), 30 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 46cddb604..61d570a15 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 @@ -60,24 +60,53 @@ class AiHandler @Inject constructor( preferencesRepo.setModel(provider, model) } + private data class GenerationParams( + val temperature: Float, + val topP: Float, + val topK: Int, + val maxTokens: Int, + val presencePenalty: Float, + val frequencyPenalty: Float, + ) + + private suspend fun getGenerationParams(): GenerationParams { + return GenerationParams( + temperature = preferencesRepo.aiTemperature.first(), + topP = preferencesRepo.aiTopP.first(), + topK = preferencesRepo.aiTopK.first(), + maxTokens = preferencesRepo.aiMaxTokens.first(), + presencePenalty = preferencesRepo.aiPresencePenalty.first(), + frequencyPenalty = preferencesRepo.aiFrequencyPenalty.first(), + ) + } + private suspend fun generateWithRecovery( provider: AiProvider, apiKey: String, systemPrompt: String, prompt: String, - temperature: Float + temperature: Float, + topP: Float, + topK: Int, + maxTokens: Int, + presencePenalty: Float, + frequencyPenalty: Float, ): String { val client = clientFactory.createClient(provider, apiKey) val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() } return try { - // Wrap in timeout to prevent hanging requests withTimeout(REQUEST_TIMEOUT_MS) { client.generateContent( requestedModel, systemPrompt, prompt, - temperature + temperature, + topP, + topK, + maxTokens, + presencePenalty, + frequencyPenalty, ) } } catch (e: kotlinx.coroutines.TimeoutCancellationException) { @@ -103,13 +132,17 @@ class AiHandler @Inject constructor( failure = failure ) ?: throw failure - // Retry with recovered model (also with timeout) withTimeout(REQUEST_TIMEOUT_MS) { client.generateContent( recoveredModel, systemPrompt, prompt, - temperature + temperature, + topP, + topK, + maxTokens, + presencePenalty, + frequencyPenalty, ) } } @@ -141,48 +174,40 @@ class AiHandler @Inject constructor( temperature: Float = 0.7f, context: String = "" ): String { - // Dynamic temperature adjustment if default value is used - val resolvedTemperature = if (temperature == 0.7f) { - when (type) { - // AI Optimization: Use low temperature for high-precision metadata to prevent hallucinations - AiSystemPromptType.METADATA -> 0.1f - AiSystemPromptType.MOOD_ANALYSIS -> 0.2f - // AI Optimization: Moderate temperature for tags to allow creative yet relevant descriptors - AiSystemPromptType.TAGGING -> 0.4f - // AI Optimization: Balanced temperature for playlists to ensure variety without losing cohesion - AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f - // AI Optimization: High temperature for persona-based responses to increase flair and engagement - AiSystemPromptType.PERSONA -> 0.85f - AiSystemPromptType.GENERAL -> 0.7f - } - } else temperature + val params = getGenerationParams() + val effectiveTemperature = if (params.temperature == 0.7f) { + if (temperature == 0.7f) { + when (type) { + AiSystemPromptType.METADATA -> 0.1f + AiSystemPromptType.MOOD_ANALYSIS -> 0.2f + AiSystemPromptType.TAGGING -> 0.4f + AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f + AiSystemPromptType.PERSONA -> 0.85f + AiSystemPromptType.GENERAL -> 0.7f + } + } else temperature + } else params.temperature - // Determine chain based on user preference val userProviderStr = preferencesRepo.aiProvider.first() val userProvider = AiProvider.fromString(userProviderStr) - // Generate combined prompt for hashing and execution val basePersona = getBasePersona(userProvider) val combinedSystemPrompt = promptEngine.buildPrompt(basePersona, type, context) - - // Cache entry is valid for a specific prompt + system instruction + provider + val hash = (userProvider.name + combinedSystemPrompt + prompt).sha256() - // Check cache with TTL — don't serve stale results cacheDao.getCache(hash)?.let { cached -> val age = System.currentTimeMillis() - cached.timestamp if (age < CACHE_TTL_MS) { return cached.responseJson } - // Cache expired — proceed with fresh generation } val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider) val failedProviders = mutableListOf() val now = System.currentTimeMillis() - + for (provider in providersToTry) { - // Skip if in cooldown val cooldownExpiry = providerCooldowns[provider] ?: 0L if (now < cooldownExpiry) { failedProviders.add("${provider.name}: on cooldown (${((cooldownExpiry - now) / 1000)}s remaining)") @@ -196,7 +221,6 @@ class AiHandler @Inject constructor( continue } - // Use the shared base persona but specialized type rules for each provider in the chain val providerPersona = getBasePersona(provider) val finalSystemPrompt = promptEngine.buildPrompt(providerPersona, type, context) @@ -205,7 +229,12 @@ class AiHandler @Inject constructor( apiKey = apiKey, systemPrompt = finalSystemPrompt, prompt = prompt, - temperature = resolvedTemperature + temperature = effectiveTemperature, + topP = params.topP, + topK = params.topK, + maxTokens = params.maxTokens, + presencePenalty = params.presencePenalty, + frequencyPenalty = params.frequencyPenalty, ) // Validate response is not empty From 2963295055e76f2f4ae02224a313f6f47aa293e6 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:15:04 +0545 Subject: [PATCH 22/30] feat(ai): make digest sample size, mode, and extended fields configurable from preferences --- .../pixelplay/data/ai/AiPlaylistGenerator.kt | 17 +++-- .../data/ai/UserProfileDigestGenerator.kt | 69 +++++++++---------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt index 3e2e5a524..3add5aeed 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt @@ -40,13 +40,13 @@ class AiPlaylistGenerator @Inject constructor( } } - // Token Optimization: Reduce sample size based on safe mode val isSafe = preferencesRepo.isSafeTokenLimitEnabled.first() - val sampleCap = if (isSafe) 40 else 80 + val prefSampleSize = preferencesRepo.aiSampleSize.first() + val useExtendedFields = preferencesRepo.aiIncludeExtendedFields.first() + val sampleCap = if (isSafe) prefSampleSize else prefSampleSize * 2 val sampleSize = max(minLength, sampleCap).coerceAtMost(sampleCap) val songSample = samplingPool.take(sampleSize) - - // Token Optimization: Compact JSON format — only essential fields + val availableSongsJson = buildString { songSample.forEachIndexed { index, song -> val score = dailyMixManager.getScore(song.id) @@ -54,7 +54,14 @@ class AiPlaylistGenerator @Inject constructor( val artist = song.displayArtist.replace("\"", "'").take(25) val genre = song.genre?.replace("\"", "'")?.take(15) ?: "?" if (index > 0) append(",\n") - append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","s":$score}""") + if (useExtendedFields) { + val album = song.album?.replace("\"", "'")?.take(25) ?: "?" + val dur = song.duration + val fav = if (song.isFavorite) "1" else "0" + append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","al":"$album","d":$dur,"f":$fav,"s":$score}""") + } else { + append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","s":$score}""") + } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt index 99c2fdb3b..e7f9bef52 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt @@ -12,43 +12,36 @@ import javax.inject.Singleton @Singleton class UserProfileDigestGenerator @Inject constructor( private val statsRepository: PlaybackStatsRepository, - private val playlistDao: LocalPlaylistDao + private val playlistDao: LocalPlaylistDao, + private val preferencesRepo: com.theveloper.pixelplay.data.preferences.AiPreferencesRepository, ) { - // Token Budget Tiers: - // SAFE: ~1000 tokens (4000 chars) — fast, cheap, still gives good results - // FULL: ~8000 tokens (32000 chars) — deep context for maximum personalization private val SAFE_TARGET_CHAR_LIMIT = 4000 private val MAX_TARGET_CHAR_LIMIT = 32000 - // Track limits per tier — prevents runaway context size private val SAFE_LISTENED_LIMIT = 15 private val SAFE_DISCOVERY_LIMIT = 30 private val FULL_LISTENED_LIMIT = 60 private val FULL_DISCOVERY_LIMIT = 120 - /** - * Computes a highly condensed representation of the user's listening profile. - * Uses a compact key-value format to minimize token consumption while maximizing signal. - * - * Safe mode aggressively caps all sections to stay under ~1000 tokens. - * Full mode provides deep context for maximum personalization quality. - */ suspend fun generateDigest(allSongs: List, isSafeLimit: Boolean = true): String { - val targetLimit = if (isSafeLimit) SAFE_TARGET_CHAR_LIMIT else MAX_TARGET_CHAR_LIMIT - val listenedLimit = if (isSafeLimit) SAFE_LISTENED_LIMIT else FULL_LISTENED_LIMIT - val discoveryLimit = if (isSafeLimit) SAFE_DISCOVERY_LIMIT else FULL_DISCOVERY_LIMIT + val digestMode = preferencesRepo.aiDigestMode.first() + val useExtendedFields = preferencesRepo.aiIncludeExtendedFields.first() + val isSafe = if (digestMode == "full") false else isSafeLimit + + val targetLimit = if (isSafe) SAFE_TARGET_CHAR_LIMIT else MAX_TARGET_CHAR_LIMIT + val listenedLimit = if (isSafe) SAFE_LISTENED_LIMIT else FULL_LISTENED_LIMIT + val discoveryLimit = if (isSafe) SAFE_DISCOVERY_LIMIT else FULL_DISCOVERY_LIMIT val summary = statsRepository.loadSummary(StatsTimeRange.ALL, allSongs) val playlists = playlistDao.observePlaylistsWithSongs().first() - + val sb = StringBuilder() sb.append("USER_PROFILE\n") - - // --- 1. Behavioral & Pattern Metrics (compact) --- + sb.append("STATS: plays=${summary.totalPlayCount}, uniq=${summary.uniqueSongs}\n") sb.append("GENRES: ${summary.topGenres.take(3).joinToString(",") { it.genre }}\n") sb.append("ARTISTS: ${summary.topArtists.take(5).joinToString(",") { it.artist }}\n") - + summary.dayListeningDistribution?.let { dist -> val phases = dist.buckets.groupBy { bucket -> val hour = bucket.startMinute / 60 @@ -61,50 +54,56 @@ class UserProfileDigestGenerator @Inject constructor( }.mapValues { it.value.sumOf { b -> b.totalDurationMs } } sb.append("PHASE: ${phases.maxByOrNull { it.value }?.key ?: "Unknown"}\n") } - + val variety = if (summary.totalPlayCount > 0) summary.uniqueSongs.toDouble() / summary.totalPlayCount else 0.0 sb.append("VAR: ${"%.2f".format(variety)}\n") - - val playlistLimit = if (isSafeLimit) 5 else 20 + + val playlistLimit = if (isSafe) 5 else 20 if (playlists.isNotEmpty()) { sb.append("PL: ${playlists.take(playlistLimit).joinToString(",") { it.playlist.name }}\n") } - - // --- 2. Listened Tracks (capped) --- - // Compact format: ID|plays|mins|fav|title-artist + sb.append("\nLISTENED: id|p|d|f|meta\n") - + val songMap = allSongs.associateBy { it.id } val playedSongs = summary.songs.take(listenedLimit) - + playedSongs.forEach { s -> if (sb.length >= (targetLimit * 0.6).toInt()) return@forEach val song = songMap[s.songId] val fav = if (song?.isFavorite == true) "1" else "0" val mins = s.totalDurationMs / 60000 - // Truncate long titles to save tokens val title = s.title.take(30) val artist = s.artist.take(20) - sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist\n") + if (useExtendedFields) { + val album = song?.album?.take(20) ?: "?" + val year = song?.year?.toString()?.take(4) ?: "?" + sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist|$album|$year\n") + } else { + sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist\n") + } } - - // --- 3. Discovery Pool (strictly capped) --- - // AI needs to know what's available but unplayed + val playedIds = summary.songs.map { it.songId }.toSet() val unplayed = allSongs.filter { it.id !in playedIds } .shuffled() .take(discoveryLimit) - + if (unplayed.isNotEmpty()) { sb.append("\nDISCOVERY_POOL:\n") unplayed.forEach { s -> if (sb.length >= targetLimit) return@forEach val title = s.title.take(30) val artist = s.displayArtist.take(20) - sb.append("${s.id}|$title-$artist\n") + if (useExtendedFields) { + val genre = s.genre?.take(15) ?: "?" + sb.append("${s.id}|$title-$artist|$genre\n") + } else { + sb.append("${s.id}|$title-$artist\n") + } } } - + return sb.toString() } } From e7a17b68b05060f2b6282b0a076f147c329196f3 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:15:30 +0545 Subject: [PATCH 23/30] feat(ai): add generation parameter flows and handlers to SettingsViewModel --- .../viewmodel/SettingsViewModel.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 41f4a5e01..d1d67f39e 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 @@ -299,6 +299,28 @@ class SettingsViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + // Generation Parameters + val aiTemperature: StateFlow = aiPreferencesRepository.aiTemperature + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.7f) + val aiTopP: StateFlow = aiPreferencesRepository.aiTopP + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.95f) + val aiTopK: StateFlow = aiPreferencesRepository.aiTopK + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 64) + val aiMaxTokens: StateFlow = aiPreferencesRepository.aiMaxTokens + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 4096) + val aiPresencePenalty: StateFlow = aiPreferencesRepository.aiPresencePenalty + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0f) + val aiFrequencyPenalty: StateFlow = aiPreferencesRepository.aiFrequencyPenalty + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0f) + + // Song Data Configuration + val aiSampleSize: StateFlow = aiPreferencesRepository.aiSampleSize + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 40) + val aiDigestMode: StateFlow = aiPreferencesRepository.aiDigestMode + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "safe") + val aiIncludeExtendedFields: StateFlow = aiPreferencesRepository.aiIncludeExtendedFields + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + fun onAiApiKeyChange(apiKey: String) { viewModelScope.launch { val providerStr = aiProvider.value @@ -393,6 +415,34 @@ class SettingsViewModel @Inject constructor( } } + fun onAiTemperatureChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiTemperature(value) } + } + fun onAiTopPChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiTopP(value) } + } + fun onAiTopKChange(value: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiTopK(value) } + } + fun onAiMaxTokensChange(value: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiMaxTokens(value) } + } + fun onAiPresencePenaltyChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiPresencePenalty(value) } + } + fun onAiFrequencyPenaltyChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiFrequencyPenalty(value) } + } + fun onAiSampleSizeChange(value: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiSampleSize(value) } + } + fun onAiDigestModeChange(mode: String) { + viewModelScope.launch { aiPreferencesRepository.setAiDigestMode(mode) } + } + fun onAiIncludeExtendedFieldsChange(enabled: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setAiIncludeExtendedFields(enabled) } + } + fun onAiModelChange(model: String) { viewModelScope.launch { val provider = AiProvider.fromString(aiProvider.value) From aba3bbfc4b10715147566186bc056ef4e0fdb251 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 19:16:22 +0545 Subject: [PATCH 24/30] feat(ui): add Generation Parameters and Song Data Configuration sections to AI settings --- .../screens/SettingsCategoryScreen.kt | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) 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 379d9c11d..32df899fd 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 @@ -1040,6 +1040,140 @@ fun SettingsCategoryScreen( ) } + // Generation Parameters Section + SettingsSubsection(title = "Generation Parameters") { + SliderSettingsItem( + label = "Temperature", + value = settingsViewModel.aiTemperature.collectAsStateWithLifecycle().value, + valueRange = 0.0f..2.0f, + steps = 20, + onValueChange = { settingsViewModel.onAiTemperatureChange(it) }, + valueText = { String.format(Locale.US, "%.2f", it) } + ) + Text( + text = "Controls randomness. Lower = more deterministic, higher = more creative.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Top P", + value = settingsViewModel.aiTopP.collectAsStateWithLifecycle().value, + valueRange = 0.0f..1.0f, + steps = 20, + onValueChange = { settingsViewModel.onAiTopPChange(it) }, + valueText = { String.format(Locale.US, "%.2f", it) } + ) + Text( + text = "Nucleus sampling. Higher = more diverse tokens considered.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Top K", + value = settingsViewModel.aiTopK.collectAsStateWithLifecycle().value.toFloat(), + valueRange = 1f..100f, + steps = 99, + onValueChange = { settingsViewModel.onAiTopKChange(it.toInt()) }, + valueText = { it.toInt().toString() } + ) + Text( + text = "Limits token selection to the K most likely candidates.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Max Output Tokens", + value = settingsViewModel.aiMaxTokens.collectAsStateWithLifecycle().value.toFloat(), + valueRange = 128f..8192f, + steps = 63, + onValueChange = { settingsViewModel.onAiMaxTokensChange(it.toInt()) }, + valueText = { it.toInt().toString() } + ) + Text( + text = "Maximum length of the AI response. Higher = longer but more expensive.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Presence Penalty", + value = settingsViewModel.aiPresencePenalty.collectAsStateWithLifecycle().value, + valueRange = -2.0f..2.0f, + steps = 40, + onValueChange = { settingsViewModel.onAiPresencePenaltyChange(it) }, + valueText = { String.format(Locale.US, "%.1f", it) } + ) + Text( + text = "Penalizes repeated topics. Positive = more diverse topics.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Frequency Penalty", + value = settingsViewModel.aiFrequencyPenalty.collectAsStateWithLifecycle().value, + valueRange = -2.0f..2.0f, + steps = 40, + onValueChange = { settingsViewModel.onAiFrequencyPenaltyChange(it) }, + valueText = { String.format(Locale.US, "%.1f", it) } + ) + Text( + text = "Penalizes repeated phrases. Positive = more natural language.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + } + + // Song Data Configuration Section + SettingsSubsection(title = "Song Data Configuration") { + val aiSampleSize by settingsViewModel.aiSampleSize.collectAsStateWithLifecycle() + SliderSettingsItem( + label = "Sample Size", + value = aiSampleSize.toFloat(), + valueRange = 10f..120f, + steps = 11, + onValueChange = { settingsViewModel.onAiSampleSizeChange(it.toInt()) }, + valueText = { "${it.toInt()} songs" } + ) + Text( + text = "Number of songs sent to the AI for playlist generation. More = better context but higher cost.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + ThemeSelectorItem( + label = "Digest Detail", + description = "Controls how much listening history data is included", + options = mapOf("safe" to "Concise (faster)", "full" to "Full (better quality)"), + selectedKey = settingsViewModel.aiDigestMode.collectAsStateWithLifecycle().value, + onSelectionChanged = { settingsViewModel.onAiDigestModeChange(it) }, + leadingIcon = { + Icon( + painterResource(R.drawable.rounded_monitoring_24), + null, + tint = MaterialTheme.colorScheme.secondary + ) + } + ) + SwitchSettingItem( + title = "Extended Song Fields", + subtitle = "Include album, year, and genre info in song data sent to AI", + checked = settingsViewModel.aiIncludeExtendedFields.collectAsStateWithLifecycle().value, + onCheckedChange = { settingsViewModel.onAiIncludeExtendedFieldsChange(it) }, + leadingIcon = { + Icon( + painterResource(R.drawable.rounded_music_note_24), + null, + tint = MaterialTheme.colorScheme.secondary + ) + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) SettingsSubsection(title = stringResource(R.string.settings_ai_usage_report_section)) { From 51049de0279a2e376572ee256f128ec137e2a156 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 21:17:27 +0545 Subject: [PATCH 25/30] fix: resolve compilation errors in AI settings UI - Move provider val to AI_INTEGRATION scope for accessibility - Remove duplicate base URL AiApiKeyItem block - Add missing imports: CircleShape, ModalBottomSheet, IconButton, OutlinedTextFieldDefaults, GeminiModel, Search, Clear, CheckCircle --- .../presentation/screens/SettingsCategoryScreen.kt | 6 ++++-- .../pixelplay/presentation/screens/SettingsComponents.kt | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) 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 32df899fd..77f426d7c 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 @@ -906,6 +906,9 @@ fun SettingsCategoryScreen( } } SettingsCategory.AI_INTEGRATION -> { + val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) + val currentCustomBaseUrl by settingsViewModel.customBaseUrl.collectAsStateWithLifecycle() + // AI Provider Selection SettingsSubsection(title = stringResource(R.string.settings_ai_provider_section)) { ThemeSelectorItem( @@ -939,7 +942,6 @@ fun SettingsCategoryScreen( // Consolidated API Key Section SettingsSubsection(title = stringResource(R.string.settings_credentials_section)) { - val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) val sourceLabel = when(provider) { com.theveloper.pixelplay.data.ai.provider.AiProvider.GEMINI -> stringResource(R.string.settings_ai_source_gemini) com.theveloper.pixelplay.data.ai.provider.AiProvider.DEEPSEEK -> stringResource(R.string.settings_ai_source_deepseek) @@ -1017,7 +1019,7 @@ fun SettingsCategoryScreen( if (provider.hasConfigurableUrl) { SettingsSubsection(title = "API Base URL") { AiApiKeyItem( - apiKey = customBaseUrl, + apiKey = settingsViewModel.customBaseUrl, onApiKeySave = { settingsViewModel.onCustomBaseUrlChange(it) }, title = "Base URL", subtitle = "e.g. https://api.example.com/v1" 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 3db71c961..bc5dbb329 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 @@ -30,18 +30,25 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Sync +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -64,6 +71,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.ai.GeminiModel import com.theveloper.pixelplay.data.worker.SyncProgress import com.theveloper.pixelplay.presentation.viewmodel.LyricsRefreshProgress import com.theveloper.pixelplay.ui.theme.GoogleSansRounded From 3f103978ae9b992fbeb32e78aaac8638a48936e2 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 21:19:07 +0545 Subject: [PATCH 26/30] fix(ai): add AiResponseCleaner, fix usage tracking model name, robust response parsing - Add AiResponseCleaner utility for cleaning JSON/text AI responses - Fix usage tracking to record actual model name instead of provider enum - Update AiPlaylistGenerator and AiMetadataGenerator to use cleaner --- .../theveloper/pixelplay/data/ai/AiHandler.kt | 87 +++++++---------- .../pixelplay/data/ai/AiMetadataGenerator.kt | 5 +- .../pixelplay/data/ai/AiPlaylistGenerator.kt | 64 +++---------- .../pixelplay/data/ai/AiResponseCleaner.kt | 93 +++++++++++++++++++ 4 files changed, 143 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt 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 61d570a15..2a17b0e6a 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 @@ -69,6 +69,11 @@ class AiHandler @Inject constructor( val frequencyPenalty: Float, ) + private data class GenerationResult( + val response: String, + val modelUsed: String, + ) + private suspend fun getGenerationParams(): GenerationParams { return GenerationParams( temperature = preferencesRepo.aiTemperature.first(), @@ -91,60 +96,43 @@ class AiHandler @Inject constructor( maxTokens: Int, presencePenalty: Float, frequencyPenalty: Float, - ): String { + ): GenerationResult { val client = clientFactory.createClient(provider, apiKey) val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() } - return try { - withTimeout(REQUEST_TIMEOUT_MS) { - client.generateContent( - requestedModel, - systemPrompt, - prompt, - temperature, - topP, - topK, - maxTokens, - presencePenalty, - frequencyPenalty, + fun callWithModel(model: String): String { + return try { + withTimeout(REQUEST_TIMEOUT_MS) { + client.generateContent( + model, systemPrompt, prompt, temperature, + topP, topK, maxTokens, presencePenalty, frequencyPenalty, + ) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException( + providerName = provider.displayName, + statusCode = null, + transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.", + responseBody = null, + requestedModel = model ) } - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException( - providerName = provider.displayName, - statusCode = null, - transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.", - responseBody = null, - requestedModel = requestedModel - ) + } + + return try { + val response = callWithModel(requestedModel) + GenerationResult(response, requestedModel) } catch (e: Exception) { val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable( - provider.displayName, - e, - requestedModel + provider.displayName, e, requestedModel ) val recoveredModel = recoverModelIfNeeded( - provider = provider, - apiKey = apiKey, - requestedModel = requestedModel, - client = client, - failure = failure + provider, apiKey, requestedModel, client, failure ) ?: throw failure - withTimeout(REQUEST_TIMEOUT_MS) { - client.generateContent( - recoveredModel, - systemPrompt, - prompt, - temperature, - topP, - topK, - maxTokens, - presencePenalty, - frequencyPenalty, - ) - } + val response = callWithModel(recoveredModel) + GenerationResult(response, recoveredModel) } } @@ -224,7 +212,7 @@ class AiHandler @Inject constructor( val providerPersona = getBasePersona(provider) val finalSystemPrompt = promptEngine.buildPrompt(providerPersona, type, context) - val response = generateWithRecovery( + val result = generateWithRecovery( provider = provider, apiKey = apiKey, systemPrompt = finalSystemPrompt, @@ -237,17 +225,14 @@ class AiHandler @Inject constructor( frequencyPenalty = params.frequencyPenalty, ) - // Validate response is not empty - if (response.isBlank()) { + if (result.response.isBlank()) { failedProviders.add("${provider.name}: returned empty response") continue } - // Low-maintenance usage tracking using highly accurate proportional estimation bounds (4 chars ~ 1 token) - // Models with "thinking" or "reasoning" generally output 2-3x internal tokens for complex generation val isThinkingModel = finalSystemPrompt.contains("think", true) || provider.name.contains("reasoning", true) val estimatedPromptTokens = (finalSystemPrompt.length + prompt.length) / 4 - val estimatedOutputTokens = response.length / 4 + val estimatedOutputTokens = result.response.length / 4 val estimatedThoughtTokens = if (isThinkingModel) (estimatedOutputTokens * 1.5).toInt() else 0 appScope.launch { @@ -256,7 +241,7 @@ class AiHandler @Inject constructor( AiUsageEntity( timestamp = now, provider = provider.displayName, - model = provider.name, + model = result.modelUsed, promptType = type.name, promptTokens = estimatedPromptTokens, outputTokens = estimatedOutputTokens, @@ -268,8 +253,8 @@ class AiHandler @Inject constructor( } } - cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = response, timestamp = System.currentTimeMillis())) - return response + cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = result.response, timestamp = System.currentTimeMillis())) + return result.response } catch (e: Exception) { // AI Optimization: Robust failover logic—if one provider fails, we log and try the next in the chain val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(provider.displayName, e) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt index eaa67258c..d6b2dba08 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt @@ -20,9 +20,6 @@ class AiMetadataGenerator @Inject constructor( private val aiHandler: AiHandler, private val json: Json ) { - private fun cleanJson(jsonString: String): String { - return jsonString.replace("```json", "").replace("```", "").trim() - } suspend fun generate( song: Song, @@ -52,7 +49,7 @@ class AiMetadataGenerator @Inject constructor( } Timber.d("AI Response: $responseText") - val cleanedJson = cleanJson(responseText) + val cleanedJson = AiResponseCleaner.cleanJsonResponse(responseText) val metadata = json.decodeFromString(cleanedJson) Result.success(metadata) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt index 3add5aeed..06b91dce4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt @@ -44,8 +44,7 @@ class AiPlaylistGenerator @Inject constructor( val prefSampleSize = preferencesRepo.aiSampleSize.first() val useExtendedFields = preferencesRepo.aiIncludeExtendedFields.first() val sampleCap = if (isSafe) prefSampleSize else prefSampleSize * 2 - val sampleSize = max(minLength, sampleCap).coerceAtMost(sampleCap) - val songSample = samplingPool.take(sampleSize) + val songSample = samplingPool.take(sampleCap) val availableSongsJson = buildString { songSample.forEachIndexed { index, song -> @@ -155,55 +154,18 @@ class AiPlaylistGenerator @Inject constructor( } private fun extractPlaylistSongIds(rawResponse: String): List { - val sanitized = rawResponse - .replace("```json", "") - .replace("```", "") - .trim() - - for (startIndex in sanitized.indices) { - if (sanitized[startIndex] != '[') continue - - var depth = 0 - var inString = false - var isEscaped = false - - for (index in startIndex until sanitized.length) { - val character = sanitized[index] - - if (inString) { - if (isEscaped) { - isEscaped = false - continue - } - - when (character) { - '\\' -> isEscaped = true - '"' -> inString = false - } - continue - } - - when (character) { - '"' -> inString = true - '[' -> depth++ - ']' -> { - depth-- - if (depth == 0) { - val candidate = sanitized.substring(startIndex, index + 1) - val decoded = runCatching { json.decodeFromString>(candidate) } - if (decoded.isSuccess) { - return decoded.getOrThrow() - } - break - } - } - } + val cleaned = AiResponseCleaner.cleanJsonResponse(rawResponse) + val jsonArray = AiResponseCleaner.extractJsonArray(cleaned) + ?: throw IllegalArgumentException( + "AI returned an invalid response format. Expected a JSON array of song IDs but got something else. " + + "This usually happens with smaller models. Try selecting a more capable model in AI Settings." + ) + + return runCatching { json.decodeFromString>(jsonArray) } + .getOrElse { + throw IllegalArgumentException( + "AI returned malformed JSON. Expected a string array but got: ${jsonArray.take(100)}" + ) } - } - - throw IllegalArgumentException( - "AI returned an invalid response format. Expected a JSON array of song IDs but got something else. " + - "This usually happens with smaller models. Try selecting a more capable model in AI Settings." - ) } } 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 new file mode 100644 index 000000000..92d6e27de --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt @@ -0,0 +1,93 @@ +package com.theveloper.pixelplay.data.ai + +object AiResponseCleaner { + + fun cleanJsonResponse(raw: String): String { + var cleaned = raw + .replace("```json", "") + .replace("```kotlin", "") + .replace("```", "") + .trim() + + if (cleaned.startsWith("[")) { + val end = findMatchingBracket(cleaned, 0) + if (end > 0) cleaned = cleaned.substring(0, end + 1) + } else if (cleaned.startsWith("{")) { + val end = findMatchingBrace(cleaned, 0) + if (end > 0) cleaned = cleaned.substring(0, end + 1) + } + + return cleaned + } + + fun cleanTextResponse(raw: String): String { + return raw + .replace("```text", "") + .replace("```", "") + .trim() + } + + fun extractJsonArray(text: String): String? { + for (i in text.indices) { + if (text[i] == '[') { + val end = findMatchingBracket(text, i) + if (end > i) return text.substring(i, end + 1) + } + } + return null + } + + fun extractJsonObject(text: String): String? { + for (i in text.indices) { + if (text[i] == '{') { + val end = findMatchingBrace(text, i) + if (end > i) return text.substring(i, end + 1) + } + } + return null + } + + private fun findMatchingBracket(text: String, start: Int): Int { + var depth = 0 + var inString = false + var escaped = false + for (i in start until text.length) { + val c = text[i] + if (escaped) { escaped = false; continue } + if (inString) { + if (c == '\\') escaped = true + else if (c == '"') inString = false + continue + } + when (c) { + '\\' -> escaped = true + '"' -> inString = true + '[' -> depth++ + ']' -> { depth--; if (depth == 0) return i } + } + } + return -1 + } + + private fun findMatchingBrace(text: String, start: Int): Int { + var depth = 0 + var inString = false + var escaped = false + for (i in start until text.length) { + val c = text[i] + if (escaped) { escaped = false; continue } + if (inString) { + if (c == '\\') escaped = true + else if (c == '"') inString = false + continue + } + when (c) { + '\\' -> escaped = true + '"' -> inString = true + '{' -> depth++ + '}' -> { depth--; if (depth == 0) return i } + } + } + return -1 + } +} From 9ad2e8d42c9a5221e2273418417cdb303f12d8b7 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 21:28:20 +0545 Subject: [PATCH 27/30] refactor: remove AiMetadataGenerator and fix compilation errors - Delete AiMetadataGenerator.kt (AI metadata generation feature) - Remove all references from AiStateHolder, PlayerViewModel, PlayerUiState - Remove generateAiMetadata callback from SongInfoBottomSheet - Strip calls from 10+ screens - Fix AiHandler callWithModel suspend modifier - Fix SettingsCategoryScreen customBaseUrl StateFlow to String - Remove encodeDefaults from GeminiAiClient Json config --- .../theveloper/pixelplay/data/ai/AiHandler.kt | 2 +- .../pixelplay/data/ai/AiMetadataGenerator.kt | 64 ------------------- .../data/ai/provider/GeminiAiClient.kt | 1 - .../components/DailyMixSection.kt | 3 - .../components/SongInfoBottomSheet.kt | 6 -- .../components/UnifiedPlayerOverlaysLayer.kt | 3 - .../presentation/screens/AlbumDetailScreen.kt | 3 - .../screens/ArtistDetailScreen.kt | 3 - .../presentation/screens/DailyMixScreen.kt | 11 +--- .../presentation/screens/GenreDetailScreen.kt | 3 - .../presentation/screens/LibraryScreen.kt | 24 +------ .../screens/PlaylistDetailScreen.kt | 3 - .../screens/RecentlyPlayedScreen.kt | 3 - .../presentation/screens/SearchScreen.kt | 3 - .../screens/SettingsCategoryScreen.kt | 2 +- .../presentation/viewmodel/AiStateHolder.kt | 56 ---------------- .../presentation/viewmodel/PlayerUiState.kt | 1 - .../presentation/viewmodel/PlayerViewModel.kt | 44 ------------- .../viewmodel/PlayerViewModelTest.kt | 1 - 19 files changed, 4 insertions(+), 232 deletions(-) delete mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt 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 2a17b0e6a..d9da7840b 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 @@ -100,7 +100,7 @@ class AiHandler @Inject constructor( val client = clientFactory.createClient(provider, apiKey) val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() } - fun callWithModel(model: String): String { + suspend fun callWithModel(model: String): String { return try { withTimeout(REQUEST_TIMEOUT_MS) { client.generateContent( diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt deleted file mode 100644 index d6b2dba08..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.theveloper.pixelplay.data.ai - - -import com.theveloper.pixelplay.data.model.Song -import kotlinx.serialization.SerializationException -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import timber.log.Timber -import javax.inject.Inject - -@Serializable -data class SongMetadata( - val title: String? = null, - val artist: String? = null, - val album: String? = null, - val genre: String? = null -) - -class AiMetadataGenerator @Inject constructor( - private val aiHandler: AiHandler, - private val json: Json -) { - - suspend fun generate( - song: Song, - fieldsToComplete: List - ): Result { - return try { - val fieldsJson = fieldsToComplete.joinToString(separator = ", ") { "\"$it\"" } - - val albumInfo = if (song.album.isNotBlank()) "${song.album}" else "" - - val fullPrompt = """ - - ${song.title} - ${song.displayArtist} - $albumInfo - - - Complete the following fields using your music knowledge: - [$fieldsJson] - - """.trimIndent() - - val responseText = aiHandler.generateContent(fullPrompt, AiSystemPromptType.METADATA) - if (responseText.isBlank()) { - Timber.e("AI returned an empty or null response.") - return Result.failure(Exception("AI returned an empty response.")) - } - - Timber.d("AI Response: $responseText") - val cleanedJson = AiResponseCleaner.cleanJsonResponse(responseText) - val metadata = json.decodeFromString(cleanedJson) - - Result.success(metadata) - } catch (e: SerializationException) { - Timber.e(e, "Error deserializing AI response.") - Result.failure(Exception("Failed to parse AI response: ${e.message}", e)) - } catch (e: Exception) { - Timber.e(e, "Generic error in AiMetadataGenerator.") - Result.failure(Exception("AI Error: ${e.message}", e)) - } - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt index c2288296a..cee7e23a4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt @@ -37,7 +37,6 @@ class GeminiAiClient(private val apiKey: String) : AiClient { private val json = Json { ignoreUnknownKeys = true isLenient = true - encodeDefaults = true } @Serializable diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt index 235d7ab0b..98eaa91b3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt @@ -159,9 +159,6 @@ fun DailyMixSection( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt index 01b934507..5dc12aa89 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt @@ -90,7 +90,6 @@ import com.theveloper.pixelplay.utils.shapes.RoundedStarShape import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.ui.theme.MontserratFamily import com.theveloper.pixelplay.presentation.viewmodel.SongInfoBottomSheetViewModel @@ -142,12 +141,7 @@ fun SongInfoBottomSheet( replayGainAlbumGainDb: String, coverArtUpdate: CoverArtUpdate? ) -> Unit, - generateAiMetadata: suspend (List) -> Result, removeFromListTrigger: () -> Unit, - isGeneratingMetadata: Boolean = false, - aiMetadataSuccess: Boolean = false, - aiError: String? = null, - onRetryMetadata: () -> Unit = {}, songInfoViewModel: SongInfoBottomSheetViewModel = hiltViewModel() ) { val context = LocalContext.current diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt index 5a7fba4fd..2cb218cce 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt @@ -256,9 +256,6 @@ internal fun UnifiedPlayerSongInfoLayer( ) onDismissSongInfo() }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(liveSong, fields) - }, removeFromListTrigger = { playerViewModel.removeSongFromQueue(liveSong.id) onDismissSongInfo() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt index a8f3f31f1..ce578abd5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt @@ -479,9 +479,6 @@ fun AlbumDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = removeFromListTrigger ) if (showPlaylistBottomSheet) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt index 6f9199237..f8f85fcfe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt @@ -535,9 +535,6 @@ fun ArtistDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = removeFromListTrigger ) if (showPlaylistBottomSheet) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt index 24785f39d..eabba3c12 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt @@ -125,8 +125,6 @@ fun DailyMixScreen( val aiStatus by playerViewModel.aiStatus.collectAsStateWithLifecycle() val aiError by playerViewModel.aiError.collectAsStateWithLifecycle() val aiSuccess by playerViewModel.aiSuccess.collectAsStateWithLifecycle() - val isGeneratingAiMetadata by playerViewModel.isGeneratingAiMetadata.collectAsStateWithLifecycle() - val aiMetadataSuccess by playerViewModel.aiMetadataSuccess.collectAsStateWithLifecycle() val lazyListState = rememberLazyListState() var showSongInfoSheet by remember { mutableStateOf(false) } @@ -233,14 +231,7 @@ fun DailyMixScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, - removeFromListTrigger = removeFromListTrigger, - isGeneratingMetadata = isGeneratingAiMetadata, - aiMetadataSuccess = aiMetadataSuccess, - aiError = aiError, - onRetryMetadata = { playerViewModel.retryLastMetadataGeneration() } + removeFromListTrigger = removeFromListTrigger ) if (showPlaylistBottomSheet) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt index f401829b6..2fe1dd6ad 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt @@ -623,9 +623,6 @@ fun GenreDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) } 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 2260b4880..5c52f1a50 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 @@ -400,7 +400,6 @@ private data class LibraryScreenPlayerProjection( val isSdCardAvailable: Boolean = false, val musicFolders: ImmutableList = persistentListOf(), val isLoadingLibraryCategories: Boolean = true, - val isGeneratingAiMetadata: Boolean = false, val isSyncingLibrary: Boolean = false, val isLoadingInitialSongs: Boolean = true, val hideLocalMedia: Boolean = false @@ -422,7 +421,6 @@ private fun PlayerUiState.toLibraryScreenProjection(): LibraryScreenPlayerProjec isSdCardAvailable = isSdCardAvailable, musicFolders = musicFolders, isLoadingLibraryCategories = isLoadingLibraryCategories, - isGeneratingAiMetadata = isGeneratingAiMetadata, isSyncingLibrary = isSyncingLibrary, isLoadingInitialSongs = isLoadingInitialSongs, hideLocalMedia = hideLocalMedia @@ -1704,24 +1702,7 @@ fun LibraryScreen( } } } - if (playerUiState.isGeneratingAiMetadata) { - Surface( // Fondo semitransparente para el indicador - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f) - ) { - Box(contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoadingIndicator(modifier = Modifier.size(64.dp)) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.library_generating_ai_metadata), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } else if ( + if ( isLibraryContentEmpty && ( playerUiState.isSyncingLibrary || @@ -1928,9 +1909,6 @@ fun LibraryScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = {}, songInfoViewModel = songInfoBottomSheetViewModel ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 646f002f0..a2b8c0c67 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -1037,9 +1037,6 @@ fun PlaylistDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = { playlistViewModel.removeSongFromPlaylist(playlistId, currentSong.id) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt index a90154869..5ebf94a74 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt @@ -335,9 +335,6 @@ fun RecentlyPlayedScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) 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 bfe566be2..eab38137e 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 @@ -753,9 +753,6 @@ fun SearchScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, ) } } 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 77f426d7c..874091ac0 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 @@ -1019,7 +1019,7 @@ fun SettingsCategoryScreen( if (provider.hasConfigurableUrl) { SettingsSubsection(title = "API Base URL") { AiApiKeyItem( - apiKey = settingsViewModel.customBaseUrl, + apiKey = currentCustomBaseUrl, onApiKeySave = { settingsViewModel.onCustomBaseUrlChange(it) }, title = "Base URL", subtitle = "e.g. https://api.example.com/v1" 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 948a0ad27..25f9f6cee 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 @@ -4,10 +4,8 @@ package com.theveloper.pixelplay.presentation.viewmodel import android.content.Context import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.DailyMixManager -import com.theveloper.pixelplay.data.ai.AiMetadataGenerator import com.theveloper.pixelplay.data.ai.AiNotificationManager import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.provider.AiProviderException import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository @@ -30,7 +28,6 @@ import javax.inject.Singleton class AiStateHolder @Inject constructor( @ApplicationContext private val context: Context, private val aiPlaylistGenerator: AiPlaylistGenerator, - private val aiMetadataGenerator: AiMetadataGenerator, private val dailyMixManager: DailyMixManager, private val playlistPreferencesRepository: PlaylistPreferencesRepository, private val dailyMixStateHolder: DailyMixStateHolder, @@ -45,12 +42,6 @@ class AiStateHolder @Inject constructor( private val _isGeneratingAiPlaylist = MutableStateFlow(false) val isGeneratingAiPlaylist = _isGeneratingAiPlaylist.asStateFlow() - private val _isGeneratingMetadata = MutableStateFlow(false) - val isGeneratingMetadata = _isGeneratingMetadata.asStateFlow() - - private val _aiMetadataSuccess = MutableStateFlow(false) - val aiMetadataSuccess = _aiMetadataSuccess.asStateFlow() - private val _aiSuccess = MutableStateFlow(false) val aiSuccess = _aiSuccess.asStateFlow() @@ -64,10 +55,6 @@ class AiStateHolder @Inject constructor( private var _lastMinLength: Int = 5 private var _lastMaxLength: Int = 15 - // Metadata Retry Cache: Stores parameters for the last metadata generation - private var _lastMetadataSong: Song? = null - private var _lastMetadataFields: List? = null - private var scope: CoroutineScope? = null private var allSongsProvider: (suspend () -> List)? = null private var favoriteSongIdsProvider: (() -> Set)? = null @@ -111,7 +98,6 @@ class AiStateHolder @Inject constructor( _showAiPlaylistSheet.value = false _aiError.value = null _aiSuccess.value = false - _aiMetadataSuccess.value = false _isGeneratingAiPlaylist.value = false _aiStatus.value = null } @@ -122,16 +108,6 @@ class AiStateHolder @Inject constructor( generateAiPlaylist(prompt, _lastMinLength, _lastMaxLength) } - fun retryLastMetadataGeneration() { - // Safe retry for metadata using cached song and requested fields - val song = _lastMetadataSong ?: return - val fields = _lastMetadataFields ?: return - - scope?.launch { - generateAiMetadata(song, fields) - } - } - fun clearAiPlaylistError() { _aiError.value = null } @@ -308,38 +284,6 @@ class AiStateHolder @Inject constructor( } } - /** - * Fetches AI-generated metadata (tags, genre, lyrics) for a specific song. - * Updates internal success and error states for UI feedback. - */ - suspend fun generateAiMetadata(song: Song, fields: List): Result { - _lastMetadataSong = song - _lastMetadataFields = fields - - _isGeneratingMetadata.value = true - _aiMetadataSuccess.value = false - _aiError.value = null - - return try { - val result = aiMetadataGenerator.generate(song, fields) - if (result.isSuccess) { - _aiMetadataSuccess.value = true - notificationManager.showCompletion("Metadata Enhanced", "Applied tags and genre refinements.") - } else { - result.exceptionOrNull()?.let { - _aiError.value = resolveAiErrorMessage(it) - notificationManager.showCompletion("Metadata Error", "Check your AI configuration.") - } - } - result - } catch (e: Exception) { - _aiError.value = resolveAiErrorMessage(e) - Result.failure(e) - } finally { - _isGeneratingMetadata.value = false - } - } - suspend fun translateLyrics(lyricsText: String): Result { return try { val targetLanguage = context.resources.configuration.locales[0].displayLanguage diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt index 4272693f5..5485efe70 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt @@ -51,7 +51,6 @@ data class PlayerUiState( val folderBackGestureNavigationEnabled: Boolean = true, val currentSongSortOption: SortOption = SortOption.SongTitleAZ, // val songCount: Int = 0, // REMOVED - val isGeneratingAiMetadata: Boolean = false, val searchHistory: ImmutableList = persistentListOf(), val searchQuery: String = "", val isSyncingLibrary: Boolean = 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 d6829b847..3868e2eae 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 @@ -37,7 +37,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.theveloper.pixelplay.R -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.data.model.Album import com.theveloper.pixelplay.data.model.Artist @@ -171,14 +170,6 @@ private data class SortOptionsSnapshot( val favoriteSort: SortOption, ) -private data class AiUiSnapshot( - val showAiPlaylistSheet: Boolean, - val isGeneratingAiPlaylist: Boolean, - val aiStatus: String?, - val aiError: String?, - val isGeneratingAiMetadata: Boolean, -) - @UnstableApi @SuppressLint("LogNotTimber") @OptIn(coil.annotation.ExperimentalCoilApi::class, ExperimentalCoroutinesApi::class) @@ -446,10 +437,6 @@ class PlayerViewModel @Inject constructor( val aiStatus: StateFlow = aiStateHolder.aiStatus val aiError: StateFlow = aiStateHolder.aiError - // AI Metadata Generation States - val isGeneratingAiMetadata: StateFlow = aiStateHolder.isGeneratingMetadata - val aiMetadataSuccess: StateFlow = aiStateHolder.aiMetadataSuccess - private val _selectedSongForInfo = MutableStateFlow(null) val selectedSongForInfo: StateFlow = _selectedSongForInfo.asStateFlow() @@ -1779,29 +1766,6 @@ class PlayerViewModel @Inject constructor( openPlayerSheetCallback = { _isSheetVisible.value = true } ) - // Collect AiStateHolder flows - viewModelScope.launch { - combine( - aiStateHolder.showAiPlaylistSheet, - aiStateHolder.isGeneratingAiPlaylist, - aiStateHolder.aiStatus, - aiStateHolder.aiError, - aiStateHolder.isGeneratingMetadata, - ) { show, generating, status, error, generatingMetadata -> - AiUiSnapshot( - showAiPlaylistSheet = show, - isGeneratingAiPlaylist = generating, - aiStatus = status, - aiError = error, - isGeneratingAiMetadata = generatingMetadata - ) - }.collect { snapshot -> - _playerUiState.update { - it.copy(isGeneratingAiMetadata = snapshot.isGeneratingAiMetadata) - } - } - } - // Initialize LibraryStateHolder libraryStateHolder.initialize(viewModelScope) @@ -2611,10 +2575,6 @@ class PlayerViewModel @Inject constructor( aiStateHolder.retryLastPlaylistGeneration() } - fun retryLastMetadataGeneration() { - aiStateHolder.retryLastMetadataGeneration() - } - fun clearQueueExceptCurrent() { mediaController?.let { controller -> val currentSongIndex = controller.currentMediaItemIndex @@ -2886,10 +2846,6 @@ class PlayerViewModel @Inject constructor( }.getOrDefault(false) } - suspend fun generateAiMetadata(song: Song, fields: List): Result { - return aiStateHolder.generateAiMetadata(song, fields) - } - private fun updateSongInStates( updatedSong: Song, newLyrics: Lyrics? = null, 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 41c9b2494..7996811c6 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 @@ -180,7 +180,6 @@ class PlayerViewModelTest { every { mockAiStateHolder.showAiPlaylistSheet } returns MutableStateFlow(false) every { mockAiStateHolder.isGeneratingAiPlaylist } returns MutableStateFlow(false) every { mockAiStateHolder.aiError } returns MutableStateFlow(null) - every { mockAiStateHolder.isGeneratingMetadata } returns MutableStateFlow(false) every { mockAiStateHolder.initialize(any(), any(), any(), any(), any(), any()) } just runs every { mockCastStateHolder.castSession } returns _castSessionFlow From e7713164e1f08adc59ab2a173bc5f158cb375124 Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 21:29:18 +0545 Subject: [PATCH 28/30] refactor: improve translateLyrics prompt with XML structure - Restructure prompt with , , , sections - Clarify timestamp preservation and ALREADY_IN_TARGET_LANGUAGE behavior --- .../presentation/viewmodel/AiStateHolder.kt | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) 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 25f9f6cee..ad68cea83 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 @@ -288,23 +288,24 @@ class AiStateHolder @Inject constructor( return try { val targetLanguage = context.resources.configuration.locales[0].displayLanguage val prompt = """ -Translate the provided song lyrics into $targetLanguage. - -Keep every timestamp exactly unchanged. - -If the lyrics are ALREADY mostly in $targetLanguage, output ONLY the exact phrase "ALREADY_IN_TARGET_LANGUAGE" without any other text. - -For each original line, output the original line first, then on the next line output the $targetLanguage translation with the same timestamp. - -Do not add any extra text, explanations, numbering, labels, or formatting. -Do not remove, merge, split, or reorder lines. - -Output only: -[timestamp] original text -[timestamp] translated text - -Lyrics to translate: +Translate song lyrics into $targetLanguage. + + +- Preserve ALL timestamps [mm:ss.xx] exactly — never modify, merge, or drop them. +- Output TWO lines per original line: the original, then the translation with the same timestamp. +- NEVER add explanations, labels, numbering, section headers, or formatting. +- NEVER remove, merge, split, or reorder lines. +- If lyrics are ALREADY mostly in $targetLanguage, output ONLY: ALREADY_IN_TARGET_LANGUAGE + + + +[original timestamp] original text +[same timestamp] translated text + + + $lyricsText + """.trimIndent() val response = aiHandler.generateContent( From 49d890ebcaf0faf303f5218032158f492137b94c Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 21:47:04 +0545 Subject: [PATCH 29/30] fix: restore playlist generation state tracking - Add AiUiSnapshot data class (showAiPlaylistSheet, isGeneratingAiPlaylist, aiStatus, aiError) - Re-add combine in PlayerViewModel init to collect AiStateHolder flows into PlayerUiState - Add showAiPlaylistSheet, isGeneratingAiPlaylist, aiStatus, aiError fields to PlayerUiState --- .../presentation/viewmodel/PlayerUiState.kt | 4 +++ .../presentation/viewmodel/PlayerViewModel.kt | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt index 5485efe70..7da60dfe4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt @@ -22,6 +22,10 @@ data class PlayerUiState( // val artists: ImmutableList = persistentListOf(), // REMOVED val searchResults: ImmutableList = persistentListOf(), val musicFolders: ImmutableList = persistentListOf(), + val showAiPlaylistSheet: Boolean = false, + val isGeneratingAiPlaylist: Boolean = false, + val aiStatus: String? = null, + val aiError: String? = null, val sortOption: SortOption = SortOption.SongDefaultOrder, val isLoadingInitialSongs: Boolean = true, val isLoadingLibrary: Boolean = true, 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 3868e2eae..f552dbfb6 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 @@ -162,6 +162,13 @@ private fun moveQueueIndex(index: Int, fromIndex: Int, toIndex: Int): Int { } } +private data class AiUiSnapshot( + val showAiPlaylistSheet: Boolean, + val isGeneratingAiPlaylist: Boolean, + val aiStatus: String?, + val aiError: String?, +) + private data class SortOptionsSnapshot( val songSort: SortOption, val albumSort: SortOption, @@ -1766,6 +1773,32 @@ class PlayerViewModel @Inject constructor( openPlayerSheetCallback = { _isSheetVisible.value = true } ) + // Collect AiStateHolder flows for playlist generation state + viewModelScope.launch { + combine( + aiStateHolder.showAiPlaylistSheet, + aiStateHolder.isGeneratingAiPlaylist, + aiStateHolder.aiStatus, + aiStateHolder.aiError, + ) { show, generating, status, error -> + AiUiSnapshot( + showAiPlaylistSheet = show, + isGeneratingAiPlaylist = generating, + aiStatus = status, + aiError = error + ) + }.collect { snapshot -> + _playerUiState.update { + it.copy( + showAiPlaylistSheet = snapshot.showAiPlaylistSheet, + isGeneratingAiPlaylist = snapshot.isGeneratingAiPlaylist, + aiStatus = snapshot.aiStatus, + aiError = snapshot.aiError + ) + } + } + } + // Initialize LibraryStateHolder libraryStateHolder.initialize(viewModelScope) From 43d79ca91bf60afbfc06011d95a4cd56d11f241a Mon Sep 17 00:00:00 2001 From: VoidX3D Date: Mon, 15 Jun 2026 22:02:32 +0545 Subject: [PATCH 30/30] fix: update API key messages and enhance AI provider key handling --- .../presentation/components/PlaylistBottomSheet.kt | 4 ++-- .../presentation/screens/LibraryScreen.kt | 2 +- .../presentation/viewmodel/PlayerViewModel.kt | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt index 2ceed9d9f..6a3dc19f3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt @@ -63,7 +63,7 @@ fun PlaylistBottomSheet( currentPlaylistId: String? = null ) { val playlistCreatedAndSongsAddedMessage = stringResource(R.string.playlist_sheet_created_and_songs_added) - val setGeminiApiKeyFirstMessage = stringResource(R.string.library_toast_set_gemini_api_key_first) + val setAiProviderApiKeyFirstMessage = stringResource(R.string.library_toast_set_ai_provider_api_key_first) val songAddedToPlaylistsMessage = stringResource(R.string.playlist_sheet_song_added_to_playlists) val commonSavedMessage = stringResource(R.string.common_saved) val saveActionText = stringResource(R.string.common_save) @@ -214,7 +214,7 @@ fun PlaylistBottomSheet( if (hasActiveAiProviderApiKey) { playerViewModel.showAiPlaylistSheet() } else { - playerViewModel.sendToast(setGeminiApiKeyFirstMessage) + playerViewModel.sendToast(setAiProviderApiKeyFirstMessage) } } ) 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 5c52f1a50..5d37d029e 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 @@ -1772,7 +1772,7 @@ fun LibraryScreen( playerViewModel.clearAiPlaylistError() showCreateAiPlaylistDialog = true } else { - Toast.makeText(context, context.getString(R.string.library_toast_set_gemini_api_key_first), Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.library_toast_set_ai_provider_api_key_first), Toast.LENGTH_SHORT).show() } }, onCreate = { name, imageUri, color, icon, songIds, cropScale, cropPanX, cropPanY, shapeType, d1, d2, d3, d4, smartRuleKey -> 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 f552dbfb6..4ddadbede 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 @@ -498,7 +498,10 @@ class PlayerViewModel @Inject constructor( aiPreferencesRepository.nvidiaApiKey, aiPreferencesRepository.kimiApiKey, aiPreferencesRepository.glmApiKey, - aiPreferencesRepository.openaiApiKey + aiPreferencesRepository.openaiApiKey, + aiPreferencesRepository.ollamaApiKey, + aiPreferencesRepository.customApiKey, + aiPreferencesRepository.openrouterApiKey ) { values -> val provider = values[0] val gemini = values[1] @@ -509,7 +512,11 @@ class PlayerViewModel @Inject constructor( val kimi = values[6] val glm = values[7] val openai = values[8] + val ollama = values[9] + val custom = values[10] + val openrouter = values[11] when (provider) { + "GEMINI" -> gemini.isNotBlank() "DEEPSEEK" -> deepseek.isNotBlank() "GROQ" -> groq.isNotBlank() "MISTRAL" -> mistral.isNotBlank() @@ -517,7 +524,10 @@ class PlayerViewModel @Inject constructor( "KIMI" -> kimi.isNotBlank() "GLM" -> glm.isNotBlank() "OPENAI" -> openai.isNotBlank() - else -> gemini.isNotBlank() + "OPENROUTER" -> openrouter.isNotBlank() + "OLLAMA" -> ollama.isNotBlank() + "CUSTOM" -> custom.isNotBlank() + else -> false } }.distinctUntilChanged() .stateIn(