Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
66be53c
perf: defer legacy migrations and non-critical loads out of init hot …
VoidX3D Jun 28, 2026
b4689a6
perf: serialize library data loading to avoid I/O contention on slow …
VoidX3D Jun 28, 2026
37ef4d2
perf: derive isLibraryContentEmpty from in-memory state instead of Ro…
VoidX3D Jun 28, 2026
7b9407d
perf: eliminate loading state flapping during sequential library load
VoidX3D Jun 28, 2026
898f756
perf: add distinctUntilChanged to playerUiState combine collectors
VoidX3D Jun 28, 2026
80fb3bf
feat(ai): add LYRICS prompt type for proper logging of translation re…
VoidX3D Jun 28, 2026
d799cd9
feat(settings): split AI settings into AI Provider tab and Generation…
VoidX3D Jun 28, 2026
08e941e
feat(ai): add SearchableProviderSelector with descriptions and live s…
VoidX3D Jun 28, 2026
b10358a
i18n: add generation parameters strings to all 11 locale files
VoidX3D Jun 28, 2026
5b0444c
cleanup: remove unused hasGeminiApiKey, songCountFlow, and repository…
VoidX3D Jun 28, 2026
273f6d9
fix: add missing LYRICS and GENERATION_PARAMETERS branches in when ex…
VoidX3D Jun 28, 2026
61702e2
fix: add missing @OptIn for ExperimentalMaterial3Api on SearchablePro…
VoidX3D Jun 28, 2026
51209dc
fix: use file-level @OptIn for ExperimentalMaterial3Api in SettingsCo…
VoidX3D Jun 28, 2026
e3e1b19
fix(ai): add live status feedback and fix toast buffer for AI generation
VoidX3D Jun 28, 2026
c8c06db
fix(search): eliminate tab-switch lag by decoupling from monolithic p…
VoidX3D Jun 28, 2026
b516f83
feat(ai): update all provider names, descriptions, endpoints, and def…
VoidX3D Jun 28, 2026
1d83e43
fix(ai): clean up stale error propagation using ConcurrentHashMap for…
VoidX3D Jun 28, 2026
c0d0614
fix: add missing import for kotlinx.coroutines.flow.first
VoidX3D Jun 28, 2026
fa9f499
fix(ai): update AI client creation to support configurable base URLs …
VoidX3D Jun 28, 2026
2b509c2
fix(ai): strip markdown formatting from lyrics responses and dynamica…
VoidX3D Jun 28, 2026
6071439
Merge VoidX3D PR #2497 into review-pr-2497
daedaevibin Jul 5, 2026
523c8f9
Fix: CJK token scaling, embedded lyrics reading, local .lrc Unicode m…
daedaevibin Jul 5, 2026
1a0361d
fix: suppress NonObservableLocale lint in AiUsage date format
daedaevibin Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import timber.log.Timber
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -27,9 +28,9 @@ class AiHandler @Inject constructor(
private val promptEngine: AiSystemPromptEngine,
@AppScope private val appScope: CoroutineScope
) {
// Cooldown timer: Provider -> Expiry Timestamp
private val providerCooldowns = mutableMapOf<AiProvider, Long>()
private val COOLDOWN_DURATION_MS = 1000L * 60 * 5 // 5 minutes
// Cooldown timer: Provider -> Expiry Timestamp (thread-safe, auto-cleans expired)
private val providerCooldowns = ConcurrentHashMap<AiProvider, Long>()
private val COOLDOWN_DURATION_MS = 1000L * 60 * 2 // 2 minutes (was 5)

// Cache TTL: 30 minutes — prevents stale results from being served indefinitely
private val CACHE_TTL_MS = 1000L * 60 * 30
Expand Down Expand Up @@ -97,7 +98,13 @@ class AiHandler @Inject constructor(
presencePenalty: Float,
frequencyPenalty: Float,
): GenerationResult {
val client = clientFactory.createClient(provider, apiKey)
val client = if (provider.hasConfigurableUrl) {
val configuredUrl = preferencesRepo.getBaseUrl(provider).first()
if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl)
else clientFactory.createClient(provider, apiKey)
} else {
clientFactory.createClient(provider, apiKey)
}
Comment on lines +101 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Blank configured URL for CUSTOM/OLLAMA silently falls through to a broken default client.

When provider.hasConfigurableUrl is true but configuredUrl is blank, this falls back to clientFactory.createClient(provider, apiKey). For CUSTOM, that produces a client with baseUrl = "" and defaultModelId = "", which will fail with an opaque network/URL error rather than a clear "configure your base URL first" message.

"

🔧 Suggested fix
         val client = if (provider.hasConfigurableUrl) {
             val configuredUrl = preferencesRepo.getBaseUrl(provider).first()
-            if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl)
-            else clientFactory.createClient(provider, apiKey)
+            if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl)
+            else throw IllegalStateException("No base URL configured for ${provider.displayName}")
         } else {
             clientFactory.createClient(provider, apiKey)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val client = if (provider.hasConfigurableUrl) {
val configuredUrl = preferencesRepo.getBaseUrl(provider).first()
if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl)
else clientFactory.createClient(provider, apiKey)
} else {
clientFactory.createClient(provider, apiKey)
}
val client = if (provider.hasConfigurableUrl) {
val configuredUrl = preferencesRepo.getBaseUrl(provider).first()
if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl)
else throw IllegalStateException("No base URL configured for ${provider.displayName}")
} else {
clientFactory.createClient(provider, apiKey)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt` around lines
101 - 107, In AiHandler’s client selection logic, a blank configured URL for
providers with hasConfigurableUrl currently falls through to
clientFactory.createClient(...) and creates a broken default client. Update this
branch so that when getBaseUrl(provider).first() is blank for CUSTOM/OLLAMA, the
code does not build a client with empty base settings; instead surface a clear
configuration error or otherwise block client creation before reaching
clientFactory.createClientWithUrl/createClient.

val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() }

suspend fun callWithModel(model: String): String {
Expand Down Expand Up @@ -163,6 +170,16 @@ class AiHandler @Inject constructor(
context: String = ""
): String {
val params = getGenerationParams()
val effectiveMaxTokens = if (type == AiSystemPromptType.LYRICS) {
val estimatedInputChars = prompt.length
val estimatedOutputChars = estimatedInputChars * 2
val cjkRatio = prompt.count { it.code in 0x4E00..0x9FFF || it.code in 0x3040..0x30FF || it.code in 0xAC00..0xD7AF }.toFloat() / estimatedInputChars.coerceAtLeast(1)
val charsPerToken = (4f - cjkRatio * 3f).coerceIn(1f, 4f)
val estimatedOutputTokens = (estimatedOutputChars / charsPerToken).toInt().coerceAtLeast(4096)
estimatedOutputTokens.coerceAtMost(16384)
} else {
params.maxTokens
}
val effectiveTemperature = if (params.temperature == 0.7f) {
if (temperature == 0.7f) {
when (type) {
Expand All @@ -171,6 +188,7 @@ class AiHandler @Inject constructor(
AiSystemPromptType.TAGGING -> 0.4f
AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f
AiSystemPromptType.PERSONA -> 0.85f
AiSystemPromptType.LYRICS -> 0.7f
AiSystemPromptType.GENERAL -> 0.7f
}
} else temperature
Expand All @@ -191,9 +209,12 @@ class AiHandler @Inject constructor(
}
}

// Clean up expired cooldowns so stale entries never accumulate
val now = System.currentTimeMillis()
providerCooldowns.entries.removeIf { it.value < now }

val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider)
val failedProviders = mutableListOf<String>()
val now = System.currentTimeMillis()

for (provider in providersToTry) {
val cooldownExpiry = providerCooldowns[provider] ?: 0L
Expand All @@ -204,7 +225,7 @@ class AiHandler @Inject constructor(

try {
val apiKey = getApiKey(provider)
if (apiKey.isBlank()) {
if (apiKey.isBlank() && provider.requiresApiKey) {
failedProviders.add("${provider.name}: no API key configured")
continue
}
Expand All @@ -220,7 +241,7 @@ class AiHandler @Inject constructor(
temperature = effectiveTemperature,
topP = params.topP,
topK = params.topK,
maxTokens = params.maxTokens,
maxTokens = effectiveMaxTokens,
presencePenalty = params.presencePenalty,
frequencyPenalty = params.frequencyPenalty,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,27 @@ object AiResponseCleaner {
}

fun cleanTextResponse(raw: String): String {
return raw
var cleaned = raw
.replace("```text", "")
.replace("```", "")
.trim()

// Remove conversational framing lines that AIs sometimes prepend
val framingPrefixes = listOf(
"Here is", "Here's", "Here are", "Sure", "Certainly",
"Of course", "I've", "I have", "The translated", "Translation:",
"Translated lyrics:", "Output:"
)
val framingPattern = framingPrefixes.joinToString("|") { Regex.escape(it) }
cleaned = cleaned.replace(Regex("^(?:$framingPattern).*\\n?", RegexOption.MULTILINE), "")

// Strip paired markdown formatting markers (**text** or __text__)
cleaned = cleaned
.replace(Regex("\\*\\*(.*?)\\*\\*"), "$1")
.replace(Regex("__(.*?)__"), "$1")
.trim()

return cleaned
}
Comment on lines +24 to 45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Framing-prefix strip can delete legitimate translated lyric lines, not just leading AI chatter.

Regex("^(?:$framingPattern).*\\n?", RegexOption.MULTILINE) matches and deletes every line in the whole response that happens to start with one of the prefixes ("Here is", "I've", "I have", "Output:", etc.), not just a conversational preamble before the actual lyrics. For lyrics without timestamps (or if the model drops the [mm:ss.xx] prefix on a line), a genuinely translated line like "I have loved you..." would be silently deleted, corrupting the translation output rather than just stripping AI chatter.

Restrict removal to genuine leading framing (e.g. only lines before the first timestamp-bracketed line, or only the first 1–2 lines of the raw response) instead of matching every line in the document.

🔧 Suggested fix
-        // Remove conversational framing lines that AIs sometimes prepend
-        val framingPrefixes = listOf(
-            "Here is", "Here's", "Here are", "Sure", "Certainly",
-            "Of course", "I've", "I have", "The translated", "Translation:",
-            "Translated lyrics:", "Output:"
-        )
-        val framingPattern = framingPrefixes.joinToString("|") { Regex.escape(it) }
-        cleaned = cleaned.replace(Regex("^(?:$framingPattern).*\\n?", RegexOption.MULTILINE), "")
+        // Remove ONLY a leading conversational framing line, if present
+        val framingPrefixes = listOf(
+            "Here is", "Here's", "Here are", "Sure", "Certainly",
+            "Of course", "I've", "I have", "The translated", "Translation:",
+            "Translated lyrics:", "Output:"
+        )
+        val framingPattern = framingPrefixes.joinToString("|") { Regex.escape(it) }
+        cleaned = cleaned.replaceFirst(Regex("^(?:$framingPattern).*\\n?"), "")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var cleaned = raw
.replace("```text", "")
.replace("```", "")
.trim()
// Remove conversational framing lines that AIs sometimes prepend
val framingPrefixes = listOf(
"Here is", "Here's", "Here are", "Sure", "Certainly",
"Of course", "I've", "I have", "The translated", "Translation:",
"Translated lyrics:", "Output:"
)
val framingPattern = framingPrefixes.joinToString("|") { Regex.escape(it) }
cleaned = cleaned.replace(Regex("^(?:$framingPattern).*\\n?", RegexOption.MULTILINE), "")
// Strip paired markdown formatting markers (**text** or __text__)
cleaned = cleaned
.replace(Regex("\\*\\*(.*?)\\*\\*"), "$1")
.replace(Regex("__(.*?)__"), "$1")
.trim()
return cleaned
}
var cleaned = raw
.replace("
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt`
around lines 24 - 45, The framing-prefix cleanup in AiResponseCleaner.clean is
too broad and can حذف legitimate translated lyric lines anywhere in the
response. Narrow the removal so it only strips leading AI chatter before the
actual lyrics start, for example by limiting it to the first one or two lines or
only content before the first timestamp-bracketed line. Keep the existing
framingPrefixes/framiningPattern logic, but replace the global multiline regex
behavior with a preamble-only pass so later lines like “I have...” or “Output:”
are preserved when they are part of the translation.


fun extractJsonArray(text: String): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum class AiSystemPromptType {
MOOD_ANALYSIS,
PERSONA,
DAILY_MIX,
LYRICS,
GENERAL
}

Expand Down Expand Up @@ -182,6 +183,16 @@ class AiSystemPromptEngine @Inject constructor() {
$dailyMixPersonaPrompt
""".trimIndent()

AiSystemPromptType.LYRICS -> """
<role>Song lyrics translator — you translate lyrics between languages while preserving structure.</role>
<strategy>
- Preserve ALL timestamps and line structure exactly.
- Output each original line followed by its translation on the next line.
- Never add explanations, labels, or formatting beyond the requested format.
- If the source is already in the target language, respond with: ALREADY_IN_TARGET_LANGUAGE
</strategy>
""".trimIndent()

AiSystemPromptType.GENERAL -> """
<role>PixelPlayer Assistant — a knowledgeable music companion.</role>
<strategy>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,47 +16,47 @@ class AiClientFactory @Inject constructor() {
* @return AiClient instance
*/
fun createClient(provider: AiProvider, apiKey: String): AiClient {
if (apiKey.isBlank()) {
if (apiKey.isBlank() && provider.requiresApiKey) {
throw IllegalArgumentException("API Key cannot be blank for ${provider.displayName}")
}

return when (provider) {
AiProvider.GEMINI -> GeminiAiClient(apiKey)
AiProvider.DEEPSEEK -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.deepseek.com",
baseUrl = "https://api.deepseek.com/v1",
defaultModelId = "deepseek-chat",
providerName = "DeepSeek"
)
AiProvider.GROQ -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.groq.com/openai/v1",
defaultModelId = "llama-3.1-8b-instant",
defaultModelId = "llama-3.3-70b-versatile",
providerName = "Groq"
)
AiProvider.MISTRAL -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.mistral.ai/v1",
defaultModelId = "mistral-large-latest",
defaultModelId = "mistral-large-2411",
providerName = "Mistral"
)
AiProvider.NVIDIA -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://integrate.api.nvidia.com/v1",
defaultModelId = "meta/llama-3.1-8b-instruct",
defaultModelId = "nvidia/llama-3.1-nemotron-70b-instruct",
providerName = "NVIDIA NIM"
)
AiProvider.KIMI -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.moonshot.cn/v1",
defaultModelId = "moonshot-v1-8k",
providerName = "Moonshot Kimi"
defaultModelId = "moonshot-v1-auto",
providerName = "Kimi"
)
AiProvider.GLM -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://open.bigmodel.cn/api/paas/v4",
defaultModelId = "glm-4",
providerName = "Zhipu GLM"
defaultModelId = "glm-4-plus",
providerName = "GLM"
)
AiProvider.OPENAI -> GenericOpenAiClient(
apiKey = apiKey,
Expand All @@ -67,7 +67,7 @@ class AiClientFactory @Inject constructor() {
AiProvider.OPENROUTER -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://openrouter.ai/api/v1",
defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free",
defaultModelId = "google/gemini-2.5-flash-preview-04-17:free",
providerName = "OpenRouter"
)
Comment on lines 67 to 72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is the OpenRouter model "google/gemini-2.5-flash-preview-04-17:free" still available or has it been deprecated?

💡 Result:

The OpenRouter model "google/gemini-2.5-flash-preview-04-17:free" (and its variants) has been deprecated and is no longer available [1][2][3]. The model reached its planned deprecation date on July 15, 2025 [4][5]. Official Google Gemini API documentation confirms that this preview model is no longer supported [6][4], and OpenRouter's platform currently lists it as unavailable [1][3]. Users attempting to access it will receive errors indicating that the model cannot be reached [7].

Citations:


Use a currently supported OpenRouter default model
google/gemini-2.5-flash-preview-04-17:free is no longer available, so this default will fail on OpenRouter. Switch the fallback to an active model ID.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt`
around lines 67 - 72, The OpenRouter fallback in AiClientFactory’s OPENROUTER
branch is using an unavailable default model ID, so update the defaultModelId
passed to GenericOpenAiClient to a currently supported OpenRouter model. Keep
the change localized to the AiClientFactory OpenRouter provider setup and ensure
the fallback value is active and valid for OpenRouter requests.

AiProvider.OLLAMA -> GenericOpenAiClient(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
package com.theveloper.pixelplay.data.ai.provider

/**
* Enum representing available AI providers
*/
enum class AiProvider(val displayName: String, val requiresApiKey: Boolean, val hasConfigurableUrl: Boolean = false) {
GEMINI("Google Gemini", requiresApiKey = true),
DEEPSEEK("DeepSeek", requiresApiKey = true),
GROQ("Groq", requiresApiKey = true),
MISTRAL("Mistral", requiresApiKey = true),
NVIDIA("NVIDIA NIM", requiresApiKey = true),
KIMI("Kimi (Moonshot)", requiresApiKey = true),
GLM("Zhipu GLM", requiresApiKey = true),
OPENAI("OpenAI", requiresApiKey = true),
OPENROUTER("OpenRouter", requiresApiKey = true),
OLLAMA("Ollama", requiresApiKey = true),
CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true);
enum class AiProvider(
val displayName: String,
val requiresApiKey: Boolean,
val hasConfigurableUrl: Boolean = false,
val description: String = ""
) {
GEMINI("Google Gemini", requiresApiKey = true, description = "Gemini 2.5 Pro/Flash — Google's latest multimodal models"),
DEEPSEEK("DeepSeek", requiresApiKey = true, description = "DeepSeek-V3 & R1 — competitive open-weight reasoning models"),
GROQ("Groq", requiresApiKey = true, description = "Llama 3, Mixtral, Gemma — ultra-fast LPU inference"),
MISTRAL("Mistral", requiresApiKey = true, description = "Mistral Large, Small, Codestral — efficient European models"),
NVIDIA("NVIDIA NIM", requiresApiKey = true, description = "NVIDIA-optimized Llama, Nemotron, and community models"),
KIMI("Kimi (Moonshot)", requiresApiKey = true, description = "Kimi k1.5 — long-context reasoning by Moonshot AI"),
GLM("Zhipu GLM", requiresApiKey = true, description = "GLM-4 — bilingual (Chinese/English) models by Zhipu AI"),
OPENAI("OpenAI", requiresApiKey = true, description = "GPT-4o, GPT-4.1, o3, o4-mini — industry-standard models"),
OPENROUTER("OpenRouter", requiresApiKey = true, description = "Single API for 300+ models across all major providers"),
OLLAMA("Ollama", requiresApiKey = true, hasConfigurableUrl = true, description = "Ollama-compatible API endpoint (configurable URL)"),
CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true, description = "Any OpenAI-compatible API endpoint");
Comment on lines +9 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file first
ast-grep outline app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt --view expanded || true

printf '\n--- AiProvider.kt ---\n'
cat -n app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt

printf '\n--- Search for requiresApiKey usage ---\n'
rg -n "requiresApiKey|hasConfigurableUrl|OLLAMA|CUSTOM|AiClientFactory|AiHandler" app/src/main/java

printf '\n--- AiClientFactory.kt ---\n'
fd -a 'AiClientFactory.kt' app/src/main/java | head -n 20
printf '\n'

Repository: Veridian-Zenith/PixelPlayer

Length of output: 25922


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- AiClientFactory.kt ---\n'
cat -n app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt | sed -n '1,180p'

printf '\n--- AiHandler.kt ---\n'
cat -n app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt | sed -n '1,320p'

printf '\n--- SettingsViewModel.kt (AI client setup) ---\n'
cat -n app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt | sed -n '1160,1215p'

printf '\n--- SettingsCategoryScreen.kt (provider UI) ---\n'
cat -n app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt | sed -n '950,1055p'

printf '\n--- AiPreferencesRepository.kt (API key accessors) ---\n'
cat -n app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt | sed -n '1,220p'

Repository: Veridian-Zenith/PixelPlayer

Length of output: 43230


Allow blank keys for configurable URL providers
AiClientFactory.createClient and AiHandler.generateContent only skip the empty-key error when provider.requiresApiKey is false, but both OLLAMA and CUSTOM still set it to true. That makes the new blank-key path ineffective for these providers, so keyless/self-hosted endpoints still fail unless a dummy key is entered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt`
around lines 9 - 19, Update the AiProvider enum so the configurable URL
providers OLLAMA and CUSTOM are treated as not requiring an API key, since
AiClientFactory.createClient and AiHandler.generateContent only bypass empty-key
validation when requiresApiKey is false. Adjust the requiresApiKey values for
these enum entries, and verify the validation path still allows blank keys
whenever hasConfigurableUrl is used for self-hosted/OpenAI-compatible endpoints.


companion object {
fun fromString(value: String): AiProvider {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1014,8 +1014,8 @@ class LyricsRepositoryImpl @Inject constructor(
}
}

val cleanArtist = song.displayArtist.replace(Regex("[^a-zA-Z0-9]"), "_")
val cleanTitle = song.title.replace(Regex("[^a-zA-Z0-9]"), "_")
val cleanArtist = song.displayArtist.replace(Regex("[^\\p{L}\\p{N}]"), "_")
val cleanTitle = song.title.replace(Regex("[^\\p{L}\\p{N}]"), "_")

for (extension in LyricsImportSecurity.supportedFileExtensions()) {
val alternativeLyricsFile = File(directory, "${cleanArtist}_${cleanTitle}.$extension")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,6 @@ interface MusicRepository {
storageFilter: com.theveloper.pixelplay.data.model.StorageFilter = com.theveloper.pixelplay.data.model.StorageFilter.ALL
): Flow<Int>

/**
* Returns the count of songs in the library.
* @return Flow emitting the current song count.
*/
fun getSongCountFlow(): Flow<Int>

/**
* Returns the count of cloud songs in the library.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,6 @@ class MusicRepositoryImpl @Inject constructor(
return songRepository.getFavoriteSongCountFlow(storageFilter)
}

override fun getSongCountFlow(): Flow<Int> {
return musicDao.getSongCount().distinctUntilChanged()
}

override fun getCloudSongCountFlow(): Flow<Int> {
return musicDao.getCloudSongCount().distinctUntilChanged()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -301,6 +302,7 @@ private fun CreationModeCard(
fun CreateAiPlaylistDialog(
visible: Boolean,
isGenerating: Boolean,
status: String?,
error: String?,
onDismiss: () -> Unit,
onGenerate: (playlistName: String?, prompt: String, minLength: Int, maxLength: Int) -> Unit
Expand Down Expand Up @@ -328,6 +330,7 @@ fun CreateAiPlaylistDialog(
) {
CreateAiPlaylistContent(
isGenerating = isGenerating,
status = status,
error = error,
onDismiss = onDismiss,
onGenerate = onGenerate
Expand All @@ -340,6 +343,7 @@ fun CreateAiPlaylistDialog(
@Composable
private fun CreateAiPlaylistContent(
isGenerating: Boolean,
status: String?,
error: String?,
onDismiss: () -> Unit,
onGenerate: (playlistName: String?, prompt: String, minLength: Int, maxLength: Int) -> Unit
Expand Down Expand Up @@ -525,6 +529,28 @@ private fun CreateAiPlaylistContent(
) {
Spacer(modifier = Modifier.height(4.dp))

if (isGenerating && !status.isNullOrBlank()) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
LoadingIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(12.dp))
Text(
text = status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}

HeroAiCard()

AiSectionCard(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.ui.graphics.vector.ImageVector
import com.theveloper.pixelplay.R

Expand Down Expand Up @@ -62,6 +63,12 @@ enum class SettingsCategory(
subtitleRes = R.string.settings_category_ai_subtitle,
iconRes = R.drawable.gemini_ai
),
GENERATION_PARAMETERS(
id = "generation_parameters",
titleRes = R.string.settings_category_generation_parameters_title,
subtitleRes = R.string.settings_category_generation_parameters_subtitle,
icon = Icons.Rounded.Tune
),
BACKUP_RESTORE(
id = "backup_restore",
titleRes = R.string.settings_category_backup_title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ private fun formatPromptType(type: String): String {
"MOOD_ANALYSIS" -> "Analysis"
"PERSONA" -> "Persona"
"DAILY_MIX" -> "Daily Mix"
"LYRICS" -> "Lyrics"
"GENERAL" -> "General"
else -> type.lowercase().replaceFirstChar { it.uppercase() }
}
Expand Down
Loading
Loading