diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
index 858ad3c78..8463b57f7 100644
--- a/.idea/appInsightsSettings.xml
+++ b/.idea/appInsightsSettings.xml
@@ -31,6 +31,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index d8b7d40b0..c932c1186 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -2,7 +2,7 @@
-
+
@@ -13,7 +13,7 @@
-
+
diff --git a/app/src/androidTest/java/com/theveloper/pixelplay/data/repository/PlaylistSongCountTest.kt b/app/src/androidTest/java/com/theveloper/pixelplay/data/repository/PlaylistSongCountTest.kt
new file mode 100644
index 000000000..ed7e1eebc
--- /dev/null
+++ b/app/src/androidTest/java/com/theveloper/pixelplay/data/repository/PlaylistSongCountTest.kt
@@ -0,0 +1,136 @@
+package com.theveloper.pixelplay.data.repository
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.preferencesDataStoreFile
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.theveloper.pixelplay.data.database.LocalPlaylistDao
+import com.theveloper.pixelplay.data.database.PixelPlayDatabase
+import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository
+import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Regression test for issue #2391:
+ * "Playlist song count doesn't update when removing songs — only when adding."
+ *
+ * Exercises the real PlaylistPreferencesRepository against an in-memory Room DB to
+ * verify that the song count exposed by userPlaylistsFlow (used by the Playlists menu)
+ * reflects removals as well as additions.
+ */
+@RunWith(AndroidJUnit4::class)
+class PlaylistSongCountTest {
+
+ private lateinit var db: PixelPlayDatabase
+ private lateinit var dao: LocalPlaylistDao
+ private lateinit var dataStore: DataStore
+ private lateinit var repo: PlaylistPreferencesRepository
+
+ @Before
+ fun setup() {
+ val context = ApplicationProvider.getApplicationContext()
+ db = Room.inMemoryDatabaseBuilder(context, PixelPlayDatabase::class.java)
+ .addCallback(PixelPlayDatabase.createRuntimeArtifactsCallback())
+ .allowMainThreadQueries()
+ .build()
+ dao = db.localPlaylistDao()
+ dataStore = PreferenceDataStoreFactory.create {
+ context.preferencesDataStoreFile("test_settings_${System.nanoTime()}")
+ }
+ val userPrefs = UserPreferencesRepository(dataStore, Json { ignoreUnknownKeys = true })
+ repo = PlaylistPreferencesRepository(dao, userPrefs)
+ }
+
+ @After
+ fun teardown() {
+ db.close()
+ }
+
+ private suspend fun countFor(playlistId: String): Int =
+ repo.userPlaylistsFlow.first().first { it.id == playlistId }.songIds.size
+
+ @Test
+ fun menuSongCount_reflectsAddAndRemove() = runTest {
+ val playlist = repo.createPlaylist(name = "J-Pop", songIds = listOf("10", "20", "30"))
+ assertEquals("initial count", 3, countFor(playlist.id))
+
+ // Remove a song — the bug report says this does NOT update the count.
+ repo.removeSongFromPlaylist(playlist.id, "20")
+ assertEquals("after removing one song", 2, countFor(playlist.id))
+
+ // Remove another.
+ repo.removeSongFromPlaylist(playlist.id, "30")
+ assertEquals("after removing a second song", 1, countFor(playlist.id))
+
+ // Adding works per the report — verify it still does.
+ repo.addSongsToPlaylist(playlist.id, listOf("40"))
+ assertEquals("after adding one song", 2, countFor(playlist.id))
+ }
+
+ /**
+ * Reproduces the real-world trigger for issue #2391: removing several songs in
+ * quick succession. Each edit does an unsynchronized read-modify-write
+ * (userPlaylistsFlow.first() -> modify -> updatePlaylist), so concurrent removals
+ * all read the same original list and the last writer wins, silently dropping the
+ * other removals. The Playlists-menu count (songIds.size) then stays stuck high.
+ */
+ @Test
+ fun concurrentRemovals_doNotLoseUpdates() = runBlocking {
+ val playlist = repo.createPlaylist(
+ name = "Race",
+ songIds = listOf("1", "2", "3", "4", "5")
+ )
+ assertEquals(5, countFor(playlist.id))
+
+ // Remove four songs concurrently — "remove one or two of them", fast.
+ coroutineScope {
+ listOf("1", "2", "3", "4").forEach { id ->
+ launch(Dispatchers.IO) { repo.removeSongFromPlaylist(playlist.id, id) }
+ }
+ }
+
+ assertEquals("All concurrent removals must persist", 1, countFor(playlist.id))
+ }
+
+ /**
+ * Walks the exact reproduction from issue #2391, asserting the fixed behaviour:
+ * the song count stays accurate after a quick removal of "one or two" songs, and
+ * a later addition does not preserve a phantom difference.
+ */
+ @Test
+ fun issue2391_quickRemoveThenAdd_keepsCountAccurate() = runBlocking {
+ // Steps 2-3: create a playlist and add a few songs.
+ val playlist = repo.createPlaylist(
+ name = "J-Pop",
+ songIds = listOf("1", "2", "3", "4", "5", "6")
+ )
+ assertEquals(6, countFor(playlist.id))
+
+ // Step 4: remove one or two of them — quickly, as fast taps do.
+ coroutineScope {
+ launch(Dispatchers.IO) { repo.removeSongFromPlaylist(playlist.id, "2") }
+ launch(Dispatchers.IO) { repo.removeSongFromPlaylist(playlist.id, "4") }
+ }
+ // Step 5: the menu count must reflect BOTH removals (the bug left it stuck high).
+ assertEquals("count after removing two songs", 4, countFor(playlist.id))
+
+ // Steps 6-7: adding more must not carry over a phantom difference.
+ repo.addSongsToPlaylist(playlist.id, listOf("7", "8"))
+ assertEquals("count after adding two songs", 6, countFor(playlist.id))
+ }
+}
diff --git a/app/src/debug/res/values-de/strings.xml b/app/src/debug/res/values-de/strings.xml
index 196c1e20f..29fbcffab 100644
--- a/app/src/debug/res/values-de/strings.xml
+++ b/app/src/debug/res/values-de/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ Pausieren, wenn Lautstärke null erreicht
+ Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird
+ Lautstärke
diff --git a/app/src/debug/res/values-fr/strings.xml b/app/src/debug/res/values-fr/strings.xml
index 196c1e20f..e7290328f 100644
--- a/app/src/debug/res/values-fr/strings.xml
+++ b/app/src/debug/res/values-fr/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ Mettre en pause quand le volume atteint zéro
+ Mettre automatiquement en pause la lecture lorsque le volume est à 0
+ Volume
diff --git a/app/src/debug/res/values-ko/strings.xml b/app/src/debug/res/values-ko/strings.xml
index 196c1e20f..c36700af8 100644
--- a/app/src/debug/res/values-ko/strings.xml
+++ b/app/src/debug/res/values-ko/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ 볼륨이 0이 되면 일시정지
+ 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다
+ 볼륨
diff --git a/app/src/debug/res/values-nb/strings.xml b/app/src/debug/res/values-nb/strings.xml
index 196c1e20f..adc9a0cc9 100644
--- a/app/src/debug/res/values-nb/strings.xml
+++ b/app/src/debug/res/values-nb/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ Sett på pause når volumet er null
+ Sett automatisk avspillingen på pause når volumet settes til 0
+ Volum
diff --git a/app/src/debug/res/values-ru/strings.xml b/app/src/debug/res/values-ru/strings.xml
index 196c1e20f..a30dfa484 100644
--- a/app/src/debug/res/values-ru/strings.xml
+++ b/app/src/debug/res/values-ru/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ Пауза при нулевой громкости
+ Автоматически приостанавливать воспроизведение, когда громкость равна 0
+ Громкость
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
index 196c1e20f..478cab606 100644
--- a/app/src/debug/res/values/strings.xml
+++ b/app/src/debug/res/values/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ Pause when volume reaches zero
+ Automatically pause playback when the volume is set to 0
+ Volume
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 68%
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..d9da7840b 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,
@@ -60,58 +60,79 @@ class AiOrchestrator @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 data class GenerationResult(
+ val response: String,
+ val modelUsed: String,
+ )
+
+ 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
- ): String {
+ temperature: Float,
+ topP: Float,
+ topK: Int,
+ maxTokens: Int,
+ presencePenalty: Float,
+ frequencyPenalty: Float,
+ ): GenerationResult {
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
+ suspend 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
- // Retry with recovered model (also with timeout)
- withTimeout(REQUEST_TIMEOUT_MS) {
- client.generateContent(
- recoveredModel,
- systemPrompt,
- prompt,
- temperature
- )
- }
+ val response = callWithModel(recoveredModel)
+ GenerationResult(response, recoveredModel)
}
}
@@ -141,48 +162,40 @@ class AiOrchestrator @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,29 +209,30 @@ class AiOrchestrator @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)
- val response = generateWithRecovery(
+ val result = generateWithRecovery(
provider = provider,
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
- 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 {
@@ -227,7 +241,7 @@ class AiOrchestrator @Inject constructor(
AiUsageEntity(
timestamp = now,
provider = provider.displayName,
- model = provider.name,
+ model = result.modelUsed,
promptType = type.name,
promptTokens = estimatedPromptTokens,
outputTokens = estimatedOutputTokens,
@@ -235,16 +249,16 @@ 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")
}
}
- 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)
- 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 +282,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
deleted file mode 100644
index f67ccb324..000000000
--- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt
+++ /dev/null
@@ -1,67 +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 aiOrchestrator: AiOrchestrator,
- private val json: Json
-) {
- private fun cleanJson(jsonString: String): String {
- return jsonString.replace("```json", "").replace("```", "").trim()
- }
-
- 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 = aiOrchestrator.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 = cleanJson(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/AiPlaylistGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt
index 7017ec6a6..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
@@ -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
@@ -40,13 +40,12 @@ 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 sampleSize = max(minLength, sampleCap).coerceAtMost(sampleCap)
- val songSample = samplingPool.take(sampleSize)
-
- // Token Optimization: Compact JSON format — only essential fields
+ val prefSampleSize = preferencesRepo.aiSampleSize.first()
+ val useExtendedFields = preferencesRepo.aiIncludeExtendedFields.first()
+ val sampleCap = if (isSafe) prefSampleSize else prefSampleSize * 2
+ val songSample = samplingPool.take(sampleCap)
+
val availableSongsJson = buildString {
songSample.forEachIndexed { index, song ->
val score = dailyMixManager.getScore(song.id)
@@ -54,7 +53,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}""")
+ }
}
}
@@ -73,7 +79,7 @@ class AiPlaylistGenerator @Inject constructor(
""".trimIndent()
- val responseText = aiOrchestrator.generateContent(fullPrompt, type)
+ val responseText = aiHandler.generateContent(fullPrompt, type)
val songIds = extractPlaylistSongIds(responseText)
@@ -148,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
+ }
+}
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
""".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")
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/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()
}
}
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
/**
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..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
@@ -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",
@@ -55,6 +70,23 @@ 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 = "",
+ defaultModelId = "",
+ providerName = "Custom Provider"
+ )
}
}
+
+ fun createClientWithUrl(provider: AiProvider, apiKey: String, baseUrl: String): AiClient {
+ val displayName = provider.displayName
+ return GenericOpenAiClient(apiKey, baseUrl.trimEnd('/'), "", displayName)
+ }
}
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..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
@@ -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,9 @@ 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),
+ OLLAMA("Ollama", requiresApiKey = true),
+ CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true);
companion object {
fun fromString(value: String): AiProvider {
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..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
@@ -88,7 +88,9 @@ internal object AiProviderSupport {
AiProvider.OPENROUTER,
AiProvider.NVIDIA,
AiProvider.KIMI,
- AiProvider.GLM
+ AiProvider.GLM,
+ AiProvider.OLLAMA,
+ AiProvider.CUSTOM
)
return buildList {
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/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt
index b730603cb..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
@@ -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()
@@ -42,7 +37,6 @@ class GeminiAiClient(private val apiKey: String) : AiClient {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
- encodeDefaults = true
}
@Serializable
@@ -55,7 +49,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
@@ -86,7 +83,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 }
@@ -96,7 +98,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)
@@ -260,8 +269,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..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)
@@ -140,9 +154,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/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"
- )
- }
-}
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()
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt
index c45d119ca..8a141e350 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt
@@ -1653,6 +1653,7 @@ interface MusicDao {
artist_id = :artistId,
artists_json = :artistsJson,
album_name = :album,
+ album_artist = :albumArtist,
genre = :genre,
track_number = :trackNumber,
disc_number = :discNumber
@@ -1665,6 +1666,7 @@ interface MusicDao {
artistId: Long,
artistsJson: String?,
album: String,
+ albumArtist: String?,
genre: String?,
trackNumber: Int,
discNumber: Int?
@@ -1678,6 +1680,7 @@ interface MusicDao {
artistId: Long,
artistsJson: String?,
album: String,
+ albumArtist: String?,
genre: String?,
trackNumber: Int,
discNumber: Int?,
@@ -1695,6 +1698,7 @@ interface MusicDao {
artistId = artistId,
artistsJson = artistsJson,
album = album,
+ albumArtist = albumArtist,
genre = genre,
trackNumber = trackNumber,
discNumber = discNumber
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveRepository.kt
index 1a3457e27..3aadb6b4b 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveRepository.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveRepository.kt
@@ -16,6 +16,7 @@ import com.theveloper.pixelplay.data.database.SongEntity
import com.theveloper.pixelplay.data.database.SourceType
import com.theveloper.pixelplay.data.database.toSong
import com.theveloper.pixelplay.data.model.Song
+import com.theveloper.pixelplay.data.stream.CloudMusicUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -526,14 +527,8 @@ class GDriveRepository @Inject constructor(
)
}
- private fun parseArtistNames(rawArtist: String): List {
- if (rawArtist.isBlank()) return listOf("Unknown Artist")
- val parsed = rawArtist.split(Regex("\\s*[,/&;+]\\s*"))
- .map { it.trim() }
- .filter { it.isNotBlank() }
- .distinct()
- return if (parsed.isEmpty()) listOf("Unknown Artist") else parsed
- }
+ private fun parseArtistNames(rawArtist: String): List =
+ CloudMusicUtils.parseArtistNames(rawArtist)
private fun toUnifiedSongId(driveFileId: String): Long {
return -(GDRIVE_SONG_ID_OFFSET + driveFileId.hashCode().toLong().absoluteValue)
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt
index 245c9b4e8..e1f7c41eb 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt
@@ -152,6 +152,7 @@ class SongMetadataEditor(
title: String,
artist: String,
album: String,
+ albumArtist: String?,
genre: String?,
trackNumber: Int,
discNumber: Int?
@@ -222,6 +223,7 @@ class SongMetadataEditor(
artistId = primaryArtistId,
artistsJson = artistsJson,
album = album,
+ albumArtist = albumArtist,
genre = genre,
trackNumber = trackNumber,
discNumber = discNumber,
@@ -525,6 +527,7 @@ class SongMetadataEditor(
title = newTitle,
artist = newArtist,
album = newAlbum,
+ albumArtist = newAlbumArtist,
genre = normalizedGenre,
trackNumber = newTrackNumber,
discNumber = newDiscNumber
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/netease/NeteaseRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/netease/NeteaseRepository.kt
index 324f8701e..aca90a4e7 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/netease/NeteaseRepository.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/netease/NeteaseRepository.kt
@@ -13,7 +13,10 @@ import com.theveloper.pixelplay.data.database.NeteasePlaylistEntity
import com.theveloper.pixelplay.data.database.NeteaseSongEntity
import com.theveloper.pixelplay.data.database.SongArtistCrossRef
import com.theveloper.pixelplay.data.database.SongEntity
+import com.theveloper.pixelplay.data.database.SourceType
+import com.theveloper.pixelplay.data.database.serializeArtistRefs
import com.theveloper.pixelplay.data.database.toSong
+import com.theveloper.pixelplay.data.model.ArtistRef
import com.theveloper.pixelplay.data.model.Song
import com.theveloper.pixelplay.data.network.netease.NeteaseApiService
import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository
@@ -486,7 +489,7 @@ class NeteaseRepository @Inject constructor(
// ─── Song URL Resolution ───────────────────────────────────────────
- suspend fun getSongUrl(songId: Long, quality: String = "exhigh"): Result {
+ suspend fun getSongUrl(songId: Long, quality: String = "lossless"): Result {
val now = System.currentTimeMillis()
val lastAttempt = lastSongUrlAttemptAtMs[songId]
if (lastAttempt != null && now - lastAttempt < songUrlRequestCooldownMs) {
@@ -507,7 +510,9 @@ class NeteaseRepository @Inject constructor(
val result = withContext(Dispatchers.IO) {
runCatching {
- val qualityFallbacks = linkedSetOf(quality, "higher", "standard")
+ // Try the requested level first (e.g. "lossless" for SVIP accounts),
+ // then degrade gracefully so non-privileged accounts still resolve a URL.
+ val qualityFallbacks = linkedSetOf(quality, "exhigh", "higher", "standard")
var lastFailure: String? = null
for (level in qualityFallbacks) {
@@ -685,6 +690,14 @@ class NeteaseRepository @Inject constructor(
)
}
+ val neteaseArtistRefs = artistNames.mapIndexed { index, artistName ->
+ ArtistRef(
+ id = toUnifiedArtistId(artistName),
+ name = artistName,
+ isPrimary = index == 0
+ )
+ }
+
val albumId = toUnifiedAlbumId(neteaseSong.albumId, neteaseSong.album)
val albumName = neteaseSong.album.ifBlank { "Unknown Album" }
albums.putIfAbsent(
@@ -725,7 +738,9 @@ class NeteaseRepository @Inject constructor(
bitrate = neteaseSong.bitrate,
sampleRate = null,
telegramChatId = null,
- telegramFileId = null
+ telegramFileId = null,
+ artistsJson = serializeArtistRefs(neteaseArtistRefs),
+ sourceType = SourceType.NETEASE
)
)
}
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..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,13 +37,23 @@ 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")
fun getSystemPrompt(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_system_prompt")
+ fun getBaseUrl(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_base_url")
}
- // Generic accessors for AiOrchestrator
+ // Generic accessors for AiHandler
fun getApiKey(provider: AiProvider): Flow =
dataStore.data.map { preferences -> preferences[Keys.getApiKey(provider)]?.trim() ?: "" }
@@ -53,6 +65,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 +86,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,12 +127,48 @@ 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)
+ val customBaseUrl: Flow = getBaseUrl(AiProvider.CUSTOM)
+
val aiProvider: Flow =
dataStore.data.map { preferences -> preferences[Keys.AI_PROVIDER] ?: "GEMINI" }
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 }
}
@@ -121,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 }
+ }
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt
index 5630700f9..3303ab89a 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt
@@ -16,6 +16,7 @@ enum class AppLanguage(val tag: String, @StringRes val labelRes: Int) {
NORWEGIAN_BOKMAL("nb", R.string.settings_language_norwegian_bokmal),
RUSSIAN("ru", R.string.settings_language_russian),
SIMPLIFIED_CHINESE("zh-CN", R.string.settings_language_chinese),
+ JAPANESE("ja", R.string.settings_language_japanese),
TURKISH("tr", R.string.settings_language_turkish);
companion object {
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/PlaylistPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/PlaylistPreferencesRepository.kt
index 647a68163..eb072ce1a 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/PlaylistPreferencesRepository.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/PlaylistPreferencesRepository.kt
@@ -21,6 +21,11 @@ class PlaylistPreferencesRepository @Inject constructor(
private val userPreferencesRepository: UserPreferencesRepository
) {
private val migrationMutex = Mutex()
+ // Serializes read-modify-write edits to playlists. Without this, concurrent edits
+ // (e.g. removing several songs in quick succession) each read the same snapshot via
+ // userPlaylistsFlow.first() and the last writer wins, silently dropping the other
+ // edits — which left the Playlists-menu song count stuck high. See issue #2391.
+ private val editMutex = Mutex()
@Volatile
private var migrationChecked = false
@@ -92,16 +97,26 @@ class PlaylistPreferencesRepository @Inject constructor(
}
suspend fun renamePlaylist(playlistId: String, newName: String) {
- ensureMigratedIfNeeded()
- val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
- val updated = existing.copy(
- name = newName,
- lastModified = System.currentTimeMillis()
- )
- localPlaylistDao.upsertPlaylist(updated.toEntity())
+ editMutex.withLock {
+ ensureMigratedIfNeeded()
+ val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
+ val updated = existing.copy(
+ name = newName,
+ lastModified = System.currentTimeMillis()
+ )
+ localPlaylistDao.upsertPlaylist(updated.toEntity())
+ }
}
suspend fun updatePlaylist(playlist: Playlist) {
+ editMutex.withLock {
+ updatePlaylistLocked(playlist)
+ }
+ }
+
+ // Persists a playlist and its songs. Caller must hold [editMutex] so the
+ // surrounding read-modify-write stays atomic.
+ private suspend fun updatePlaylistLocked(playlist: Playlist) {
ensureMigratedIfNeeded()
val updated = playlist.copy(lastModified = System.currentTimeMillis())
localPlaylistDao.upsertPlaylist(updated.toEntity())
@@ -109,10 +124,12 @@ class PlaylistPreferencesRepository @Inject constructor(
}
suspend fun addSongsToPlaylist(playlistId: String, songIdsToAdd: List) {
- ensureMigratedIfNeeded()
- val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
- val merged = (existing.songIds + songIdsToAdd).distinct()
- updatePlaylist(existing.copy(songIds = merged))
+ editMutex.withLock {
+ ensureMigratedIfNeeded()
+ val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
+ val merged = (existing.songIds + songIdsToAdd).distinct()
+ updatePlaylistLocked(existing.copy(songIds = merged))
+ }
}
suspend fun addOrRemoveSongFromPlaylists(songId: String, playlistIds: List): MutableList {
@@ -137,15 +154,19 @@ class PlaylistPreferencesRepository @Inject constructor(
}
suspend fun removeSongFromPlaylist(playlistId: String, songIdToRemove: String) {
- ensureMigratedIfNeeded()
- val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
- updatePlaylist(existing.copy(songIds = existing.songIds.filterNot { it == songIdToRemove }))
+ editMutex.withLock {
+ ensureMigratedIfNeeded()
+ val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
+ updatePlaylistLocked(existing.copy(songIds = existing.songIds.filterNot { it == songIdToRemove }))
+ }
}
suspend fun reorderSongsInPlaylist(playlistId: String, newSongOrderIds: List) {
- ensureMigratedIfNeeded()
- val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
- updatePlaylist(existing.copy(songIds = newSongOrderIds))
+ editMutex.withLock {
+ ensureMigratedIfNeeded()
+ val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return
+ updatePlaylistLocked(existing.copy(songIds = newSongOrderIds))
+ }
}
suspend fun setPlaylistSongOrderMode(playlistId: String, modeValue: String) =
@@ -177,15 +198,17 @@ class PlaylistPreferencesRepository @Inject constructor(
}
suspend fun removeSongFromAllPlaylists(songId: String) {
- ensureMigratedIfNeeded()
- val playlists = userPlaylistsFlow.first()
- playlists.forEach { playlist ->
- if (songId in playlist.songIds) {
- updatePlaylist(
- playlist.copy(
- songIds = playlist.songIds.filterNot { it == songId }
+ editMutex.withLock {
+ ensureMigratedIfNeeded()
+ val playlists = userPlaylistsFlow.first()
+ playlists.forEach { playlist ->
+ if (songId in playlist.songIds) {
+ updatePlaylistLocked(
+ playlist.copy(
+ songIds = playlist.songIds.filterNot { it == songId }
+ )
)
- )
+ }
}
}
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt
index 9efca552a..4598f1a86 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt
@@ -244,6 +244,7 @@ class UserPreferencesRepository @Inject constructor(
// ReplayGain
val REPLAYGAIN_ENABLED = booleanPreferencesKey("replaygain_enabled")
val REPLAYGAIN_USE_ALBUM_GAIN = booleanPreferencesKey("replaygain_use_album_gain")
+ val PAUSE_ON_VOLUME_ZERO = booleanPreferencesKey("pause_on_volume_zero")
val SHOW_SCROLLBAR = booleanPreferencesKey("show_scrollbar")
}
@@ -745,6 +746,19 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
}
}
+ // ─── Pause on volume zero ─────────────────────────────────────────────────
+
+ val pauseOnVolumeZeroFlow: Flow =
+ dataStore.data.map { preferences ->
+ preferences[PreferencesKeys.PAUSE_ON_VOLUME_ZERO] ?: false
+ }
+
+ suspend fun setPauseOnVolumeZero(enabled: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[PreferencesKeys.PAUSE_ON_VOLUME_ZERO] = enabled
+ }
+ }
+
val showScrollbarFlow: Flow =
dataStore.data.map { preferences ->
preferences[PreferencesKeys.SHOW_SCROLLBAR] ?: true
@@ -995,7 +1009,11 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
// ─── Multi-artist settings ────────────────────────────────────────────────
val artistDelimitersFlow: Flow> =
- pref { decodeJsonPref(it, PreferencesKeys.ARTIST_DELIMITERS, DEFAULT_ARTIST_DELIMITERS) }
+ pref {
+ normalizeLegacyDefaultArtistDelimiters(
+ decodeJsonPref(it, PreferencesKeys.ARTIST_DELIMITERS, DEFAULT_ARTIST_DELIMITERS)
+ )
+ }
suspend fun setArtistDelimiters(delimiters: List) {
if (delimiters.isEmpty()) return
@@ -1347,7 +1365,9 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
companion object {
/** Default character delimiters for splitting multi-artist tags. */
- val DEFAULT_ARTIST_DELIMITERS = listOf("/", ";", ",", "+", "&")
+ val DEFAULT_ARTIST_DELIMITERS = listOf(";")
+
+ private val LEGACY_DEFAULT_ARTIST_DELIMITERS = listOf("/", ";", ",", "+", "&")
/** Default word-based delimiters matched case-insensitively with whitespace boundaries. */
val DEFAULT_ARTIST_WORD_DELIMITERS = listOf(
@@ -1360,6 +1380,9 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
// ─── Private utilities ────────────────────────────────────────────────────
+ private fun normalizeLegacyDefaultArtistDelimiters(delimiters: List): List =
+ if (delimiters == LEGACY_DEFAULT_ARTIST_DELIMITERS) DEFAULT_ARTIST_DELIMITERS else delimiters
+
/** Increments [value] by 1, wrapping back to 0 on overflow. */
private fun incrementWrapped(value: Int?) =
if (value == null || value == Int.MAX_VALUE) 0 else value + 1
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
index 48dde7b34..adab461ca 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
@@ -8,6 +8,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
+import android.database.ContentObserver
import android.graphics.Bitmap
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
@@ -15,7 +16,10 @@ import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.os.SystemClock
+import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.graphics.drawable.toBitmap
@@ -230,8 +234,27 @@ class MusicService : MediaLibraryService() {
private var shouldResumeAfterHeadsetReconnect = false
private var lastNoisyPauseRealtimeMs = 0L
private var resumeOnHeadsetReconnectEnabled = false
+ private var pauseOnVolumeZeroEnabled = false
private var temporaryForegroundStartedInOnCreate = false
+ // Observes the device's media stream volume and pauses playback when it
+ // reaches 0, if the user has enabled the "pause on volume zero" preference.
+ private val systemVolumeObserver by lazy {
+ object : ContentObserver(Handler(Looper.getMainLooper())) {
+ override fun onChange(selfChange: Boolean) {
+ if (!pauseOnVolumeZeroEnabled) return
+ val streamVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
+ if (streamVolume == 0) {
+ val player = mediaSession?.player ?: engine.masterPlayer
+ if (player.isPlaying) {
+ player.pause()
+ Timber.tag(TAG).d("pauseOnVolumeZero: paused because system media volume reached 0")
+ }
+ }
+ }
+ }
+ }
+
companion object {
private const val TAG = "MusicService_PixelPlay"
const val NOTIFICATION_ID = 101
@@ -411,6 +434,7 @@ class MusicService : MediaLibraryService() {
syncLocalListeningStatsFromPlayer(engine.masterPlayer)
engine.masterPlayer.addListener(playerListener)
+ registerSystemVolumeObserver()
// Handle player swaps (crossfade) to keep MediaSession in sync
engine.setOnPlayerAboutToBeReleasedListener { oldPlayer ->
@@ -492,6 +516,12 @@ class MusicService : MediaLibraryService() {
}
}
+ serviceScope.launch {
+ userPreferencesRepository.pauseOnVolumeZeroFlow.collect { enabled ->
+ pauseOnVolumeZeroEnabled = enabled
+ }
+ }
+
serviceScope.launch {
userPreferencesRepository.persistentShuffleEnabledFlow.collect { enabled ->
persistentShuffleEnabled = enabled
@@ -1219,6 +1249,13 @@ class MusicService : MediaLibraryService() {
private val playerListener = object : Player.Listener {
override fun onVolumeChanged(volume: Float) {
replayGainProcessor.onPlayerVolumeChanged(volume)
+ if (pauseOnVolumeZeroEnabled && volume == 0f) {
+ val player = mediaSession?.player ?: engine.masterPlayer
+ if (player.isPlaying) {
+ player.pause()
+ Timber.tag(TAG).d("pauseOnVolumeZero: paused playback because volume reached 0")
+ }
+ }
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
@@ -1464,6 +1501,7 @@ class MusicService : MediaLibraryService() {
widgetUpdateManager.cancel()
castSyncCoordinator.stop()
unregisterHeadsetReconnectMonitor()
+ unregisterSystemVolumeObserver()
wearStatePublisher.clearState()
replayGainProcessor.cancel()
@@ -1526,6 +1564,18 @@ class MusicService : MediaLibraryService() {
clearHeadsetReconnectResume()
}
+ private fun registerSystemVolumeObserver() {
+ contentResolver.registerContentObserver(
+ Settings.System.CONTENT_URI,
+ true,
+ systemVolumeObserver
+ )
+ }
+
+ private fun unregisterSystemVolumeObserver() {
+ runCatching { contentResolver.unregisterContentObserver(systemVolumeObserver) }
+ }
+
private fun maybeResumeAfterHeadsetReconnect() {
if (!resumeOnHeadsetReconnectEnabled || !shouldResumeAfterHeadsetReconnect) return
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudMusicUtils.kt b/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudMusicUtils.kt
index ff86e0d1c..2bbe8da32 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudMusicUtils.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudMusicUtils.kt
@@ -1,5 +1,7 @@
package com.theveloper.pixelplay.data.stream
+import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
+import com.theveloper.pixelplay.utils.splitArtistsByDelimiters
import org.json.JSONObject
/**
@@ -26,13 +28,13 @@ object CloudMusicUtils {
return result
}
- /** Split a raw artist string like "A, B & C" into individual names. */
+ /** Split a raw artist string using the same conservative defaults as local library sync. */
fun parseArtistNames(rawArtist: String): List {
if (rawArtist.isBlank()) return listOf("Unknown Artist")
- val parsed = rawArtist.split(Regex("\\s*[,/&;+、]\\s*"))
- .map { it.trim() }
- .filter { it.isNotBlank() }
- .distinct()
+ val parsed = rawArtist.splitArtistsByDelimiters(
+ delimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
+ wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
+ )
return if (parsed.isEmpty()) listOf("Unknown Artist") else parsed
}
}
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/data/worker/AlbumGroupingUtils.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtils.kt
index c5e877c4a..2e017b99b 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtils.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtils.kt
@@ -67,7 +67,9 @@ internal fun buildAlbumGroupingKeys(album: AlbumEntity): List
internal fun chooseAlbumDisplayArtist(
songs: List,
- preferAlbumArtist: Boolean
+ preferAlbumArtist: Boolean,
+ artistDelimiters: List = emptyList(),
+ wordDelimiters: List = emptyList()
): String {
if (songs.isEmpty()) return "Unknown Artist"
@@ -78,7 +80,13 @@ internal fun chooseAlbumDisplayArtist(
)
val trackArtist = mostCommonValue(
songs.map { song ->
- song.artistName.normalizeMetadataTextOrEmpty()
+ collectArtistNames(
+ rawArtistName = song.artistName,
+ title = song.title,
+ artistDelimiters = artistDelimiters,
+ wordDelimiters = wordDelimiters,
+ extractFromTitle = true
+ ).firstOrNull().normalizeMetadataTextOrEmpty()
}
)
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt
index 02d160a44..46ed85698 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt
@@ -620,7 +620,9 @@ constructor(
val representativeAlbumArt = songsInAlbum.firstNotNullOfOrNull { it.albumArtUriString }
val determinedAlbumArtist = chooseAlbumDisplayArtist(
songs = songsInAlbum,
- preferAlbumArtist = groupByAlbumArtist
+ preferAlbumArtist = groupByAlbumArtist,
+ artistDelimiters = artistDelimiters,
+ wordDelimiters = wordDelimiters
)
val determinedAlbumArtistId = resolveAlbumDisplayArtistId(
displayArtist = determinedAlbumArtist,
@@ -1218,6 +1220,10 @@ constructor(
const val PROGRESS_TOTAL = "progress_total"
const val PROGRESS_PHASE = "progress_phase"
const val OUTPUT_TOTAL_SONGS = "output_total_songs"
+ // Number of Telegram songs processed per DB flush. Keeps peak memory bounded
+ // regardless of channel size (e.g. 65k songs → ~130 flushes of 500 each).
+ private const val TELEGRAM_SYNC_CHUNK_SIZE = 500
+
private const val NETEASE_SONG_ID_OFFSET = 3_000_000_000_000L
private const val NETEASE_ALBUM_ID_OFFSET = 4_000_000_000_000L
private const val NETEASE_ARTIST_ID_OFFSET = 5_000_000_000_000L
@@ -1313,216 +1319,235 @@ constructor(
}
}
- // Logic to sync Telegram songs into main DB with Unified Library Support
+ // Logic to sync Telegram songs into main DB with Unified Library Support.
+ //
+ // Memory safety: songs are processed and flushed to the DB in chunks of
+ // TELEGRAM_SYNC_CHUNK_SIZE so we never hold the full 65k-song list in memory
+ // alongside the four derived collections (songs/albums/artists/crossRefs).
+ // Each chunk is inserted immediately and then GC-eligible before the next
+ // chunk is allocated. The full song ID set is collected across all chunks
+ // so deletion of removed songs still works correctly at the end.
private suspend fun syncTelegramData() {
Log.i(TAG, "Syncing Telegram songs to main database (Unified Mode)...")
try {
val telegramSongs = telegramDao.getAllTelegramSongs().first()
val channels = telegramDao.getAllChannels().first().associateBy { it.chatId }
val existingUnifiedTelegramIds = musicDao.getAllTelegramSongIds()
-
- if (telegramSongs.isEmpty()) {
+
+ if (telegramSongs.isEmpty()) {
if (existingUnifiedTelegramIds.isNotEmpty()) {
musicDao.clearAllTelegramSongs()
}
Log.d(TAG, "No Telegram songs to sync.")
- return
+ return
}
- // 1. Pre-load Local Data for Merging
- val existingArtists = musicDao.getAllArtistsListRaw().associate { it.name.trim().lowercase() to it.id }
- val existingAlbums = musicDao.getAllAlbumsList(emptyList(), false, 0).associate { "${it.title.trim().lowercase()}_${it.artistName.trim().lowercase()}" to it.id }
- val existingArtistImageUrls = musicDao.getAllArtistsListRaw().associate { it.id to it.imageUrl }
- val nextArtistId = AtomicLong((musicDao.getMaxArtistId() ?: 0L) + 1)
+ // 1. Pre-load local data for merging — loaded once, shared across all chunks.
+ // getAllArtistsListRaw() called once only (was called twice before).
+ val allExistingArtists = musicDao.getAllArtistsListRaw()
+ val existingArtists = allExistingArtists.associate { it.name.trim().lowercase() to it.id }
+ val existingAlbums = musicDao.getAllAlbumsList(emptyList(), false, 0)
+ .associate { "${it.title.trim().lowercase()}_${it.artistName.trim().lowercase()}" to it.id }
+ val existingArtistImageUrls = allExistingArtists.associate { it.id to it.imageUrl }
val delimiters = userPreferencesRepository.artistDelimitersFlow.first()
val wordDelims = userPreferencesRepository.artistWordDelimitersFlow.first()
- val songsToInsert = mutableListOf()
- val artistsToInsert = mutableMapOf() // Map to dedup by ID
- val albumsToInsert = mutableMapOf() // Map to dedup by ID
- val crossRefsToInsert = mutableListOf()
-
- telegramSongs.forEach { tSong ->
- val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream"
- // Synthetic negative ID for Song to check existence, but we want to merge metadata
- // We use negative IDs for songs to definitively identify them as Telegram-sourced in the DB
- // This prevents collision with MediaStore numeric IDs.
- val songId = -(tSong.id.hashCode().toLong().absoluteValue)
- val finalSongId = if (songId == 0L) -1L else songId
-
- // 2. Metadata Refinement (ID3 for Downloaded Files)
- var realTitle = tSong.title
- var realArtistName = tSong.artist
- var realAlbumName = channelName
- var realDateAdded = tSong.dateAdded
- var realYear = 0
- var realTrackNumber = 0
- var realDiscNumber: Int? = null
- var realAlbumArtist = "Telegram"
- var realGenre: String? = null
- var realLyrics: String? = null
- var realDuration = tSong.duration
- var realBitrate: Int? = null
- var realSampleRate: Int? = null
- var resolvedAlbumArtUri = tSong.resolveAlbumArtUri()
-
- val file = java.io.File(tSong.filePath)
- if (tSong.filePath.isNotEmpty() && file.exists()) {
- try {
- AudioMetadataReader.read(file, readArtwork = false)?.let { meta ->
- if (!meta.title.isNullOrBlank()) realTitle = meta.title
- if (!meta.artist.isNullOrBlank()) realArtistName = meta.artist
- if (!meta.album.isNullOrBlank()) realAlbumName = meta.album
- if (!meta.albumArtist.isNullOrBlank()) {
- realAlbumArtist = meta.albumArtist
- } else if (!realArtistName.isBlank()) {
- realAlbumArtist = realArtistName
+ // Collect every synced song ID across chunks so we can diff deletions at the end.
+ val syncedTelegramSongIds = HashSet(telegramSongs.size)
+ // Track album song counts across all chunks so cross-chunk albums get the right total.
+ val albumSongCounts = mutableMapOf()
+ var totalSynced = 0
+
+ telegramSongs.chunked(TELEGRAM_SYNC_CHUNK_SIZE).forEach { chunk ->
+ // Per-chunk collections — allocated, used, then released each iteration.
+ val songsToInsert = ArrayList(chunk.size)
+ val artistsToInsert = mutableMapOf()
+ val albumsToInsert = mutableMapOf()
+ val crossRefsToInsert = mutableListOf()
+
+ chunk.forEach { tSong ->
+ val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream"
+ val songId = -(tSong.id.hashCode().toLong().absoluteValue)
+ val finalSongId = if (songId == 0L) -1L else songId
+ syncedTelegramSongIds.add(finalSongId)
+
+ // 2. Metadata Refinement (ID3 for Downloaded Files)
+ var realTitle = tSong.title
+ var realArtistName = tSong.artist
+ var realAlbumName = channelName
+ var realDateAdded = tSong.dateAdded
+ var realYear = 0
+ var realTrackNumber = 0
+ var realDiscNumber: Int? = null
+ var realAlbumArtist = "Telegram"
+ var realGenre: String? = null
+ var realLyrics: String? = null
+ var realDuration = tSong.duration
+ var realBitrate: Int? = null
+ var realSampleRate: Int? = null
+ var resolvedAlbumArtUri = tSong.resolveAlbumArtUri()
+
+ val file = java.io.File(tSong.filePath)
+ if (tSong.filePath.isNotEmpty() && file.exists()) {
+ try {
+ AudioMetadataReader.read(file, readArtwork = false)?.let { meta ->
+ if (!meta.title.isNullOrBlank()) realTitle = meta.title
+ if (!meta.artist.isNullOrBlank()) realArtistName = meta.artist
+ if (!meta.album.isNullOrBlank()) realAlbumName = meta.album
+ if (!meta.albumArtist.isNullOrBlank()) {
+ realAlbumArtist = meta.albumArtist
+ } else if (!realArtistName.isBlank()) {
+ realAlbumArtist = realArtistName
+ }
+ if (!meta.genre.isNullOrBlank()) realGenre = meta.genre
+ if (!meta.lyrics.isNullOrBlank()) realLyrics = meta.lyrics
+ if (meta.trackNumber != null) realTrackNumber = meta.trackNumber
+ if (meta.discNumber != null) realDiscNumber = meta.discNumber
+ if (meta.year != null) realYear = meta.year
+ if (meta.durationMs != null && meta.durationMs > 0L) realDuration = meta.durationMs
+ if (meta.bitrate != null && meta.bitrate > 0) realBitrate = meta.bitrate
+ if (meta.sampleRate != null && meta.sampleRate > 0) realSampleRate = meta.sampleRate
}
- if (!meta.genre.isNullOrBlank()) realGenre = meta.genre
- if (!meta.lyrics.isNullOrBlank()) realLyrics = meta.lyrics
- if (meta.trackNumber != null) realTrackNumber = meta.trackNumber
- if (meta.discNumber != null) realDiscNumber = meta.discNumber
- if (meta.year != null) realYear = meta.year
- if (meta.durationMs != null && meta.durationMs > 0L) realDuration = meta.durationMs
- if (meta.bitrate != null && meta.bitrate > 0) realBitrate = meta.bitrate
- if (meta.sampleRate != null && meta.sampleRate > 0) realSampleRate = meta.sampleRate
+ resolvedAlbumArtUri = tSong.resolveAlbumArtUri()
+ } catch (e: Exception) {
+ // Ignore read errors, fall back to TdApi metadata
}
- resolvedAlbumArtUri = tSong.resolveAlbumArtUri()
- } catch (e: Exception) {
- // Ignore read errors, fall back to TdApi metadata
- }
- }
-
- // 3. Multi-Artist Processing
- val rawArtistName = if (realArtistName.isBlank()) "Unknown Artist" else realArtistName
- val splitArtists = rawArtistName.splitArtistsByDelimiters(delimiters, wordDelims)
-
- // Process Primary Artist (First in list)
- val primaryArtistName = splitArtists.firstOrNull()?.trim() ?: "Unknown Artist"
-
- var primaryArtistId = -1L
-
- splitArtists.forEachIndexed { index, individualArtistName ->
- val cleanName = individualArtistName.trim()
- val lowerName = cleanName.lowercase()
-
- // Check if artist exists locally (Merge logic)
- val existingId = existingArtists[lowerName]
-
- val finalArtistId = if (existingId != null) {
- existingId // Use Positive MediaStore ID
- } else {
- // Generate consistent negative ID for Telegram-only artist
- val synthId = -(cleanName.hashCode().toLong().absoluteValue)
- if (synthId == 0L) -1L else synthId
}
- if (index == 0) primaryArtistId = finalArtistId
-
- // Add to Artist Insert Map
- if (!artistsToInsert.containsKey(finalArtistId)) {
- artistsToInsert[finalArtistId] = ArtistEntity(
- id = finalArtistId,
- name = cleanName,
- trackCount = 0, // Will be recalculated by Room or logic
- imageUrl = existingArtistImageUrls[finalArtistId] // Keep existing image if merging
- )
+ // 3. Multi-Artist Processing
+ val rawArtistName = if (realArtistName.isBlank()) "Unknown Artist" else realArtistName
+ val splitArtists = rawArtistName.splitArtistsByDelimiters(delimiters, wordDelims)
+ var primaryArtistId = -1L
+
+ splitArtists.forEachIndexed { index, individualArtistName ->
+ val cleanName = individualArtistName.trim()
+ val lowerName = cleanName.lowercase()
+ val existingId = existingArtists[lowerName]
+ val finalArtistId = if (existingId != null) {
+ existingId
+ } else {
+ val synthId = -(cleanName.hashCode().toLong().absoluteValue)
+ if (synthId == 0L) -1L else synthId
+ }
+ if (index == 0) primaryArtistId = finalArtistId
+ if (!artistsToInsert.containsKey(finalArtistId)) {
+ artistsToInsert[finalArtistId] = ArtistEntity(
+ id = finalArtistId,
+ name = cleanName,
+ trackCount = 0,
+ imageUrl = existingArtistImageUrls[finalArtistId]
+ )
+ }
+ crossRefsToInsert.add(SongArtistCrossRef(
+ songId = finalSongId,
+ artistId = finalArtistId,
+ isPrimary = (index == 0)
+ ))
}
- // Add Cross Ref
- crossRefsToInsert.add(SongArtistCrossRef(
- songId = finalSongId,
- artistId = finalArtistId,
- isPrimary = (index == 0)
- ))
- }
-
- // 4. Album Logic
- // Try to match existing album by Name + Album Artist
- val albumKey = "${realAlbumName.trim().lowercase()}_${realAlbumArtist.trim().lowercase()}"
- val existingAlbumId = existingAlbums[albumKey]
-
- val finalAlbumId = if (existingAlbumId != null) {
- existingAlbumId // Merge with local album
- } else {
- // Synthetic negative ID
- val synthId = -(realAlbumName.hashCode().toLong().absoluteValue)
- if (synthId == 0L) -1L else synthId
- }
-
- if (!albumsToInsert.containsKey(finalAlbumId)) {
- albumsToInsert[finalAlbumId] = AlbumEntity(
+ // 4. Album Logic
+ val albumKey = "${realAlbumName.trim().lowercase()}_${realAlbumArtist.trim().lowercase()}"
+ val existingAlbumId = existingAlbums[albumKey]
+ val finalAlbumId = if (existingAlbumId != null) {
+ existingAlbumId
+ } else {
+ val synthId = -(realAlbumName.hashCode().toLong().absoluteValue)
+ if (synthId == 0L) -1L else synthId
+ }
+ // Always put the album in albumsToInsert (not just first occurrence) so that
+ // when this chunk is flushed the updated songCount upsert reaches the DB,
+ // even if this album was first seen in a previous chunk.
+ albumsToInsert[finalAlbumId] = AlbumEntity(
id = finalAlbumId,
title = realAlbumName,
- artistName = realAlbumArtist,
- artistId = primaryArtistId, // Link to primary song artist (or album artist if we resolved it properly)
- songCount = 0,
+ artistName = realAlbumArtist,
+ artistId = primaryArtistId,
+ songCount = 0, // overwritten with correct count before upsert below
dateAdded = realDateAdded,
year = realYear,
albumArtUriString = resolvedAlbumArtUri
)
+
+ // 5. Build Final Song Entity
+ val telegramArtistRefs = splitArtists.mapIndexed { idx, name ->
+ val cleanName = name.trim()
+ val lowerName = cleanName.lowercase()
+ val artId = existingArtists[lowerName]
+ ?: artistsToInsert.values.find { it.name.equals(cleanName, ignoreCase = true) }?.id
+ ?: 0L
+ ArtistRef(id = artId, name = cleanName, isPrimary = idx == 0)
+ }.filter { it.name.isNotEmpty() }
+
+ songsToInsert.add(SongEntity(
+ id = finalSongId,
+ title = realTitle,
+ artistName = rawArtistName,
+ artistId = primaryArtistId,
+ albumName = realAlbumName,
+ albumId = finalAlbumId,
+ albumArtist = realAlbumArtist,
+ duration = realDuration,
+ contentUriString = "telegram://${tSong.chatId}/${tSong.messageId}",
+ albumArtUriString = resolvedAlbumArtUri,
+ filePath = tSong.filePath,
+ parentDirectoryPath = File(tSong.filePath).parent ?: "/Telegram/$channelName",
+ dateAdded = tSong.dateAdded,
+ genre = realGenre,
+ trackNumber = realTrackNumber,
+ discNumber = realDiscNumber,
+ year = realYear,
+ isFavorite = false,
+ lyrics = realLyrics,
+ mimeType = tSong.mimeType,
+ bitrate = realBitrate,
+ sampleRate = realSampleRate,
+ telegramChatId = tSong.chatId,
+ telegramFileId = tSong.fileId,
+ artistsJson = serializeArtistRefs(telegramArtistRefs),
+ sourceType = SourceType.TELEGRAM
+ ))
+ }
+
+ // Accumulate album song counts across chunks — albums can span chunk boundaries.
+ songsToInsert.forEach { song ->
+ albumSongCounts[song.albumId] = (albumSongCounts[song.albumId] ?: 0) + 1
}
- // 5. Build Final Song Entity
- // Build artists JSON from the split artists and their resolved IDs
- val telegramArtistRefs = splitArtists.mapIndexed { idx, name ->
- val cleanName = name.trim()
- val lowerName = cleanName.lowercase()
- val artId = existingArtists[lowerName]
- ?: artistsToInsert.values.find { it.name.equals(cleanName, ignoreCase = true) }?.id
- ?: 0L
- ArtistRef(id = artId, name = cleanName, isPrimary = idx == 0)
- }.filter { it.name.isNotEmpty() }
-
- val songEntity = SongEntity(
- id = finalSongId,
- title = realTitle,
- artistName = rawArtistName, // Store full string for display
- artistId = primaryArtistId,
- albumName = realAlbumName,
- albumId = finalAlbumId,
- albumArtist = realAlbumArtist,
- duration = realDuration,
- contentUriString = "telegram://${tSong.chatId}/${tSong.messageId}",
- albumArtUriString = resolvedAlbumArtUri,
- filePath = tSong.filePath,
- parentDirectoryPath = File(tSong.filePath).parent ?: "/Telegram/$channelName",
- dateAdded = tSong.dateAdded,
- genre = realGenre,
- trackNumber = realTrackNumber,
- discNumber = realDiscNumber,
- year = realYear,
- isFavorite = false,
- lyrics = realLyrics,
- mimeType = tSong.mimeType,
- bitrate = realBitrate,
- sampleRate = realSampleRate,
- telegramChatId = tSong.chatId,
- telegramFileId = tSong.fileId,
- artistsJson = serializeArtistRefs(telegramArtistRefs),
- sourceType = SourceType.TELEGRAM
+ // Flush this chunk to DB. albumSongCounts already reflects all songs seen so far
+ // across chunks, so the count may be updated again in a later chunk upsert — that
+ // is fine because incrementalSyncMusicData uses upsert (INSERT OR REPLACE), so
+ // the last chunk to touch an album wins with the final correct count.
+ val finalAlbums = albumsToInsert.values.map { album ->
+ album.copy(songCount = albumSongCounts[album.id] ?: 0)
+ }
+ musicDao.incrementalSyncMusicData(
+ songs = songsToInsert,
+ albums = finalAlbums,
+ artists = artistsToInsert.values.toList(),
+ crossRefs = crossRefsToInsert,
+ deletedSongIds = emptyList() // Deletions handled after all chunks
)
- songsToInsert.add(songEntity)
+ totalSynced += songsToInsert.size
+ Log.d(TAG, "Telegram sync: flushed chunk of ${songsToInsert.size} songs ($totalSynced / ${telegramSongs.size} total)")
+ // chunk-local collections go out of scope here and are GC-eligible
}
-
- // Calculate song counts for the albums we are inserting
- val albumCounts = songsToInsert.groupingBy { it.albumId }.eachCount()
- val finalAlbums = albumsToInsert.values.map { album ->
- album.copy(songCount = albumCounts[album.id] ?: 0)
- }
- val syncedTelegramSongIds = songsToInsert.map { it.id }.toHashSet()
+ // Delete songs that are no longer present in the Telegram DB.
val deletedUnifiedSongIds = existingUnifiedTelegramIds.filterNot { it in syncedTelegramSongIds }
+ if (deletedUnifiedSongIds.isNotEmpty()) {
+ deletedUnifiedSongIds.chunked(500).forEach { batch ->
+ musicDao.incrementalSyncMusicData(
+ songs = emptyList(),
+ albums = emptyList(),
+ artists = emptyList(),
+ crossRefs = emptyList(),
+ deletedSongIds = batch
+ )
+ }
+ Log.i(TAG, "Telegram sync: removed ${deletedUnifiedSongIds.size} deleted songs.")
+ }
- // Upsert into MusicDao
- musicDao.incrementalSyncMusicData(
- songs = songsToInsert,
- albums = finalAlbums,
- artists = artistsToInsert.values.toList(),
- crossRefs = crossRefsToInsert,
- deletedSongIds = deletedUnifiedSongIds
- )
- Log.i(TAG, "Synced ${songsToInsert.size} Telegram songs with Unified Metadata.")
+ Log.i(TAG, "Synced $totalSynced Telegram songs with Unified Metadata.")
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Telegram data", e)
}
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/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/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/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt
index cb6e3d400..8a30f6f41 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt
@@ -956,7 +956,10 @@ fun FullPlayerContent(
onDismissLyricsSearch = { playerViewModel.resetLyricsSearchState() },
lyricsSyncOffset = lyricsSyncOffset,
onLyricsSyncOffsetChange = { currentSong?.id?.let { songId -> playerViewModel.setLyricsSyncOffset(songId, it) } },
- lyricsTextStyle = MaterialTheme.typography.titleLarge,
+ // Use the platform default font (fontFamily = null) for lyrics so extended
+ // Unicode glyphs (e.g. Icelandic æ ð þ) render instead of tofu. The bundled
+ // Google Sans Rounded variable font drops these codepoints at runtime. (#2427)
+ lyricsTextStyle = MaterialTheme.typography.titleLarge.copy(fontFamily = null),
colorScheme = LocalMaterialTheme.current,
onBackClick = { showLyricsSheet = false },
onSaveLyricsToFile = playerViewModel::saveLyricsToFile,
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt
index fd82b4fe9..fb04d8a3d 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt
@@ -39,6 +39,7 @@ import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.FilterList
import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.Dataset
+import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -451,7 +452,11 @@ fun Breadcrumbs(
modifier = Modifier.size(36.dp),
enabled = currentFolder != null
) {
- Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.common_back))
+ val icon = if (currentFolder == null) Icons.Rounded.Home else Icons.AutoMirrored.Rounded.ArrowBack
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(if (currentFolder == null) R.string.nav_bar_home else R.string.common_back)
+ )
}
Spacer(Modifier.width(8.dp))
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt
index fdb1b7b19..459e68aaf 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt
@@ -83,6 +83,9 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -130,7 +133,6 @@ private val CoreMaintainer = Contributor(
avatarUrl = "https://avatars.githubusercontent.com/u/26845343?v=4",
iconRes = R.drawable.round_developer_board_24,
githubUrl = "https://github.com/theovilardo",
- telegramUrl = "https://t.me/thevelopersupport",
)
private val PinnedCommunityMembers = listOf(
@@ -497,6 +499,7 @@ private fun AboutHeroCard(
) {
val heroShape = AbsoluteSmoothCornerShape(30.dp, 60)
val haptic = LocalHapticFeedback.current
+ val context = LocalContext.current
Surface(
modifier = modifier,
@@ -578,6 +581,82 @@ private fun AboutHeroCard(
Spacer(modifier = Modifier.height(12.dp))
CommunitySignalsRow()
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SocialChip(
+ label = stringResource(R.string.about_github_label),
+ subtitle = stringResource(R.string.about_github_subtitle),
+ iconRes = R.drawable.github,
+ contentDescription = stringResource(R.string.about_cd_open_github_repo),
+ onClick = { openUrl(context, "https://github.com/theovilardo/PixelPlayer") },
+ modifier = Modifier.weight(1f),
+ )
+ SocialChip(
+ label = stringResource(R.string.about_telegram_label),
+ subtitle = stringResource(R.string.about_telegram_subtitle),
+ iconRes = R.drawable.telegram,
+ contentDescription = stringResource(R.string.about_cd_join_telegram),
+ onClick = { openUrl(context, "https://t.me/thevelopersupport") },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SocialChip(
+ label: String,
+ subtitle: String,
+ @DrawableRes iconRes: Int,
+ contentDescription: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ onClick = onClick,
+ modifier = modifier
+ .height(52.dp)
+ .clearAndSetSemantics {
+ this.contentDescription = contentDescription
+ this.role = Role.Button
+ },
+ shape = AbsoluteSmoothCornerShape(14.dp, 60),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
+ tonalElevation = 1.dp,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Column(
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
}
}
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..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
@@ -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 ||
@@ -1791,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 ->
@@ -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 d1ff2628b..dd1672dbc 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
@@ -806,6 +806,16 @@ fun SettingsCategoryScreen(
)
}
+ SettingsSubsection(title = stringResource(R.string.settings_volume_section)) {
+ SwitchSettingItem(
+ title = stringResource(R.string.settings_pause_on_volume_zero),
+ subtitle = stringResource(R.string.settings_pause_on_volume_zero_desc),
+ checked = uiState.pauseOnVolumeZero,
+ onCheckedChange = { settingsViewModel.setPauseOnVolumeZero(it) },
+ leadingIcon = { Icon(painterResource(R.drawable.rounded_volume_down_24), null, tint = MaterialTheme.colorScheme.secondary) }
+ )
+ }
+
SettingsSubsection(title = stringResource(R.string.settings_headphones_section)) {
SwitchSettingItem(
title = stringResource(R.string.settings_headphones_resume_title),
@@ -906,6 +916,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 +952,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)
@@ -950,6 +962,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 +1013,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 = currentCustomBaseUrl,
+ 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),
@@ -1026,6 +1052,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)) {
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..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
@@ -362,14 +370,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,
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt
index e239280b8..ebf271854 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt
@@ -348,15 +348,20 @@ fun TelegramDashboardScreen(
)
},
confirmButton = {
- TextButton(onClick = {
- viewModel.removeChannel(channel.chatId)
- channelPendingRemoval = null
- }) {
+ FilledTonalButton(
+ onClick = {
+ viewModel.removeChannel(channel.chatId)
+ channelPendingRemoval = null
+ },
+ colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ )
+ ) {
Text(
text = stringResource(R.string.telegram_remove_channel_confirm_action),
fontFamily = GoogleSansRounded,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.error
+ fontWeight = FontWeight.SemiBold
)
}
},
@@ -427,8 +432,7 @@ private fun ExpressiveChannelItem(
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 14.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp)
+ .padding(horizontal = 16.dp, vertical = 14.dp)
) {
// ── Channel header row ──────────────────────────────────────
Row(
@@ -486,6 +490,8 @@ private fun ExpressiveChannelItem(
}
}
+ Spacer(modifier = Modifier.height(14.dp))
+
// ── Meta pills ──────────────────────────────────────────────
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -515,6 +521,8 @@ private fun ExpressiveChannelItem(
}
}
+ Spacer(modifier = Modifier.height(14.dp))
+
// ── Action buttons ──────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
@@ -593,6 +601,7 @@ private fun ExpressiveChannelItem(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
+ Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
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..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
@@ -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,12 +28,11 @@ 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,
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
@@ -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,62 +284,31 @@ 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
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 = aiOrchestrator.generateContent(
+ val response = aiHandler.generateContent(
prompt = prompt,
type = AiSystemPromptType.GENERAL,
temperature = 0.1f
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..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,
@@ -51,7 +55,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..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
@@ -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
@@ -163,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,
@@ -171,14 +177,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 +444,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()
@@ -504,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]
@@ -515,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()
@@ -523,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(
@@ -1779,25 +1783,28 @@ class PlayerViewModel @Inject constructor(
openPlayerSheetCallback = { _isSheetVisible.value = true }
)
- // Collect AiStateHolder flows
+ // Collect AiStateHolder flows for playlist generation state
viewModelScope.launch {
combine(
aiStateHolder.showAiPlaylistSheet,
aiStateHolder.isGeneratingAiPlaylist,
aiStateHolder.aiStatus,
aiStateHolder.aiError,
- aiStateHolder.isGeneratingMetadata,
- ) { show, generating, status, error, generatingMetadata ->
+ ) { show, generating, status, error ->
AiUiSnapshot(
showAiPlaylistSheet = show,
isGeneratingAiPlaylist = generating,
aiStatus = status,
- aiError = error,
- isGeneratingAiMetadata = generatingMetadata
+ aiError = error
)
}.collect { snapshot ->
_playerUiState.update {
- it.copy(isGeneratingAiMetadata = snapshot.isGeneratingAiMetadata)
+ it.copy(
+ showAiPlaylistSheet = snapshot.showAiPlaylistSheet,
+ isGeneratingAiPlaylist = snapshot.isGeneratingAiPlaylist,
+ aiStatus = snapshot.aiStatus,
+ aiError = snapshot.aiError
+ )
}
}
}
@@ -2611,10 +2618,6 @@ class PlayerViewModel @Inject constructor(
aiStateHolder.retryLastPlaylistGeneration()
}
- fun retryLastMetadataGeneration() {
- aiStateHolder.retryLastMetadataGeneration()
- }
-
fun clearQueueExceptCurrent() {
mediaController?.let { controller ->
val currentSongIndex = controller.currentMediaItemIndex
@@ -2886,10 +2889,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/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt
index d07d8b3df..abba7eace 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
@@ -68,6 +68,7 @@ data class SettingsUiState(
val launchTab: String = LaunchTab.HOME,
val keepPlayingInBackground: Boolean = true,
val disableCastAutoplay: Boolean = false,
+ val pauseOnVolumeZero: Boolean = false,
val resumeOnHeadsetReconnect: Boolean = false,
val showQueueHistory: Boolean = true,
val isCrossfadeEnabled: Boolean = false,
@@ -154,6 +155,7 @@ private sealed interface SettingsUiUpdate {
data class Group2(
val keepPlayingInBackground: Boolean,
val disableCastAutoplay: Boolean,
+ val pauseOnVolumeZero: Boolean,
val resumeOnHeadsetReconnect: Boolean,
val showQueueHistory: Boolean,
val isCrossfadeEnabled: Boolean,
@@ -275,6 +277,52 @@ 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), "")
+
+ // 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
@@ -349,6 +397,53 @@ 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 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 {
@@ -366,6 +461,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 +480,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 +499,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 {
@@ -547,6 +648,7 @@ class SettingsViewModel @Inject constructor(
combine(
userPreferencesRepository.keepPlayingInBackgroundFlow,
userPreferencesRepository.disableCastAutoplayFlow,
+ userPreferencesRepository.pauseOnVolumeZeroFlow,
userPreferencesRepository.resumeOnHeadsetReconnectFlow,
userPreferencesRepository.showQueueHistoryFlow,
userPreferencesRepository.isCrossfadeEnabledFlow,
@@ -568,29 +670,31 @@ class SettingsViewModel @Inject constructor(
SettingsUiUpdate.Group2(
keepPlayingInBackground = values[0] as Boolean,
disableCastAutoplay = values[1] as Boolean,
- resumeOnHeadsetReconnect = values[2] as Boolean,
- showQueueHistory = values[3] as Boolean,
- isCrossfadeEnabled = values[4] as Boolean,
- hiFiModeEnabled = values[5] as Boolean,
- crossfadeDuration = values[6] as Int,
- persistentShuffleEnabled = values[7] as Boolean,
- folderBackGestureNavigation = values[8] as Boolean,
- lyricsSourcePreference = values[9] as LyricsSourcePreference,
- autoScanLrcFiles = values[10] as Boolean,
- blockedDirectories = @Suppress("UNCHECKED_CAST") (values[11] as Set),
- hapticsEnabled = values[12] as Boolean,
- immersiveLyricsEnabled = values[13] as Boolean,
- immersiveLyricsTimeout = values[14] as Long,
- animatedLyricsBlurEnabled = values[15] as Boolean,
- animatedLyricsBlurStrength = values[16] as Float,
- disableBlurAllOver = values[17] as Boolean,
- showScrollbar = values[18] as Boolean
+ pauseOnVolumeZero = values[2] as Boolean,
+ resumeOnHeadsetReconnect = values[3] as Boolean,
+ showQueueHistory = values[4] as Boolean,
+ isCrossfadeEnabled = values[5] as Boolean,
+ hiFiModeEnabled = values[6] as Boolean,
+ crossfadeDuration = values[7] as Int,
+ persistentShuffleEnabled = values[8] as Boolean,
+ folderBackGestureNavigation = values[9] as Boolean,
+ lyricsSourcePreference = values[10] as LyricsSourcePreference,
+ autoScanLrcFiles = values[11] as Boolean,
+ blockedDirectories = @Suppress("UNCHECKED_CAST") (values[12] as Set),
+ hapticsEnabled = values[13] as Boolean,
+ immersiveLyricsEnabled = values[14] as Boolean,
+ immersiveLyricsTimeout = values[15] as Long,
+ animatedLyricsBlurEnabled = values[16] as Boolean,
+ animatedLyricsBlurStrength = values[17] as Float,
+ disableBlurAllOver = values[18] as Boolean,
+ showScrollbar = values[19] as Boolean
)
}.collect { update ->
_uiState.update { state ->
state.copy(
keepPlayingInBackground = update.keepPlayingInBackground,
disableCastAutoplay = update.disableCastAutoplay,
+ pauseOnVolumeZero = update.pauseOnVolumeZero,
resumeOnHeadsetReconnect = update.resumeOnHeadsetReconnect,
showQueueHistory = update.showQueueHistory,
isCrossfadeEnabled = update.isCrossfadeEnabled,
@@ -866,6 +970,12 @@ class SettingsViewModel @Inject constructor(
}
}
+ fun setPauseOnVolumeZero(enabled: Boolean) {
+ viewModelScope.launch {
+ userPreferencesRepository.setPauseOnVolumeZero(enabled)
+ }
+ }
+
fun setResumeOnHeadsetReconnect(enabled: Boolean) {
viewModelScope.launch {
userPreferencesRepository.setResumeOnHeadsetReconnect(enabled)
@@ -1149,7 +1259,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() }
diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml
index 78d187b68..afced2d2d 100644
--- a/app/src/main/res/values-ar/strings_settings.xml
+++ b/app/src/main/res/values-ar/strings_settings.xml
@@ -297,4 +297,13 @@
وحدات عدد %1$d · إصدار %2$s · إصدار المخطط البرمجي %3$d
Korean (الكورية)
Norwegian (النرويجية بوكمول)
+ GitHub
+ مستودع الكود
+ تليجرام
+ الدعم
+ فتح مستودع GitHub
+ الانضمام إلى مجتمع تليجرام
+ مستوى الصوت
+ إيقاف مؤقت عند وصول مستوى الصوت إلى الصفر
+ إيقاف التشغيل تلقائيًا مؤقتًا عندما يكون مستوى الصوت 0
diff --git a/app/src/main/res/values-de/strings_home_screen.xml b/app/src/main/res/values-de/strings_home_screen.xml
index 5d87a6d3c..61bc62114 100644
--- a/app/src/main/res/values-de/strings_home_screen.xml
+++ b/app/src/main/res/values-de/strings_home_screen.xml
@@ -9,9 +9,9 @@
Musik aus deinen Cloud-Accounts streamen
- Beta 0.7.0
+ Beta 0.7.5
β
- Willkommen bei PixelPlayer 0.7.0-beta
+ Willkommen bei PixelPlayer 0.7.5-beta
Du verwendest eine Beta-Version, die Fehler, Abstürze oder experimentelle Funktionen enthalten kann. Hilf uns bei der Verbesserung, indem du Probleme meldest.
Was dich erwartet
Fehler, Abstürze oder unvollständige Funktionen können unerwartet auftreten.
@@ -274,4 +274,4 @@
%1$d Song
%1$d Songs
Woche %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml
index 69f37192e..af152070d 100644
--- a/app/src/main/res/values-de/strings_settings.xml
+++ b/app/src/main/res/values-de/strings_settings.xml
@@ -174,6 +174,7 @@
Koreanisch
Norwegisch Bokmål
Türkisch
+ Japanisch
App-Design
Hell, Dunkel oder System-Design – ganz nach Geschmack.
Hell
@@ -633,8 +634,17 @@
Open-Source-Mitwirkende
Aktuelle Mitwirkenden-Liste von GitHub.
%1$d Beiträge
+ GitHub
+ Repository
+ Telegram
+ Support
+ GitHub-Repository öffnen
+ Der Telegram-Community beitreten
GitHub-Profil öffnen
Telegram öffnen
Avatar von %1$s
Icon von %1$s
+ Lautstärke
+ Pausieren, wenn Lautstärke null erreicht
+ Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird
diff --git a/app/src/main/res/values-es/strings_home_screen.xml b/app/src/main/res/values-es/strings_home_screen.xml
index f48c8c03e..0ce25db99 100644
--- a/app/src/main/res/values-es/strings_home_screen.xml
+++ b/app/src/main/res/values-es/strings_home_screen.xml
@@ -9,9 +9,9 @@
Transmite música desde tus cuentas en la nube
- Beta 0.7.0
+ Beta 0.7.5
β
- Bienvenido a PixelPlayer 0.7.0-beta
+ Bienvenido a PixelPlayer 0.7.5-beta
Estás usando una versión beta que puede contener errores, fallos o funciones experimentales. Ayúdanos a mejorar informando de los problemas.
Qué esperar
Pueden ocurrir errores, fallos o funciones incompletas de forma inesperada.
@@ -274,4 +274,4 @@
%1$d canción
%1$d canciones
Semana %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml
index b66c4aa60..0ae71e815 100644
--- a/app/src/main/res/values-es/strings_settings.xml
+++ b/app/src/main/res/values-es/strings_settings.xml
@@ -174,6 +174,7 @@
Coreano
Noruego (Bokmål)
Turco
+ Japonés
Tema de la app
Cambia entre claro, oscuro o seguir el sistema.
Tema claro
@@ -633,8 +634,17 @@
Colaboradores de código abierto
Lista de colaboradores en vivo desde GitHub.
%1$d contrib.
+ GitHub
+ Repositorio
+ Telegram
+ Soporte
+ Abrir repositorio de GitHub
+ Unirse a la comunidad de Telegram
Abrir perfil de GitHub
Abrir Telegram
Avatar de %1$s
Icono de %1$s
+ Volumen
+ Pausar cuando el volumen llegue a cero
+ Pausar automáticamente la reproducción cuando el volumen sea 0
diff --git a/app/src/main/res/values-fr/strings_changelogs.xml b/app/src/main/res/values-fr/strings_changelogs.xml
index 380efb769..9aabbbb0c 100644
--- a/app/src/main/res/values-fr/strings_changelogs.xml
+++ b/app/src/main/res/values-fr/strings_changelogs.xml
@@ -4,129 +4,167 @@
Voir sur GitHub
Améliorations
Corrections
- Nouveautés
- Ajouté
+ Quoi de neuf
+ Ajouts
+
- - Support Chromecast pour diffuser l\'audio depuis votre appareil.
+ - Prise en charge de Chromecast pour diffuser l’audio depuis votre appareil.
- Journal des modifications intégré pour vous tenir informé des dernières fonctionnalités.
- - Support des fichiers .LRC, intégrés et externes.
+ - Prise en charge des fichiers .LRC, intégrés et externes.
- Support des paroles hors ligne.
- - Paroles synchronisées (synchronisées avec le titre).
- - Nouvel écran pour voir la file d\'attente complète.
- - Réorganiser et supprimer des titres de la file d\'attente.
- - Gestes du mini-lecteur (glisser vers le bas pour fermer).
- - Ajout de plus d\'animations Material.
- - Nouveaux paramètres pour personnaliser l\'apparence.
- - Nouveaux paramètres pour vider le cache.
-
+ - Paroles synchronisées avec la musique.
+ - Nouvel écran pour afficher la file d’attente complète.
+ - Réorganisation et suppression de morceaux dans la file d’attente.
+ - Gestes du mini-lecteur (balayer vers le bas pour fermer).
+ - Ajout de nouvelles animations Material.
+ - Nouveaux paramètres pour personnaliser l’apparence.
+ - Nouveau paramètre pour vider le cache.
+
+
- - Refonte complète de l\'interface utilisateur.
+ - Refonte complète de l’interface utilisateur.
- Refonte complète du lecteur.
- - Améliorations de performance dans la bibliothèque.
- - Vitesse de démarrage de l\'application améliorée.
- - L\'IA fournit maintenant de meilleurs résultats.
+ - Amélioration des performances de la bibliothèque.
+ - Amélioration de la vitesse de démarrage de l’application.
+ - L’IA fournit désormais de meilleurs résultats.
+
- - Correction de divers bugs dans l\'éditeur de tags.
- - Correction d\'un bug où la notification de lecture ne se fermait pas.
- - Correction de plusieurs bugs qui faisaient planter l\'application.
+ - Correction de divers bugs dans l’éditeur de tags.
+ - Correction d’un bug où la notification de lecture ne se supprimait pas.
+ - Correction de plusieurs bugs provoquant des plantages.
+
- - Introduction d\'un centre de statistiques d\'écoute plus riche avec des analyses plus approfondies de vos sessions.
- - Lancement d\'un lecteur rapide flottant pour ouvrir et prévisualiser instantanément les fichiers locaux.
- - Ajout d\'un onglet dossiers avec un navigateur en arborescence et une vue prête pour playlist.
+ - Introduction d’un hub de statistiques d’écoute plus riche avec des analyses détaillées.
+ - Ajout d’un lecteur flottant rapide pour ouvrir et prévisualiser les fichiers locaux.
+ - Ajout d’un onglet dossiers avec navigation en arbre et vue playlist.
+
- - Interface Material 3 globale affinée pour une expérience plus épurée et cohérente.
- - L\'édition de métadonnées supporte maintenant le changement de pochette.
- - Animations et transitions adoucies dans toute l\'application pour une navigation plus fluide.
- - Mise en page de l\'écran artiste améliorée avec plus de détails et de peaufinage.
- - Génération DailyMix et YourMix améliorée avec des sélections plus intelligentes et diversifiées.
- - Renforcement de la génération de playlist IA.
- - Pertinence et présentation de la recherche améliorées pour une découverte plus rapide.
- - Support élargi pour une plus large gamme de formats de fichiers audio.
-
+ - Amélioration de l’interface Material 3 pour une expérience plus cohérente.
+ - L’édition des métadonnées prend désormais en charge la pochette d’album.
+ - Animations et transitions plus fluides dans toute l’application.
+ - Amélioration de la page artiste avec plus de détails et de finition.
+ - Amélioration des playlists DailyMix et YourMix avec des sélections plus variées.
+ - Amélioration du moteur de génération de playlists IA.
+ - Recherche améliorée pour une découverte plus rapide.
+ - Support élargi pour davantage de formats audio.
+
+
- - Problèmes de métadonnées résolus pour que les détails des titres restent précis partout.
- - Raccourcis de notification restaurés pour revenir de manière fiable à la lecture.
+ - Correction de problèmes de métadonnées incohérentes.
+ - Restauration des raccourcis de notification vers la lecture.
+
- Refonte majeure de la navigation
- - Nouvel explorateur de fichiers pour choisir les répertoires sources
- - Nouvelles fonctionnalités de connectivité et de diffusion
- - Continuité transparente entre appareils distants
- - Transition sans coupure entre les titres
+ - Nouveau explorateur de fichiers pour choisir les dossiers sources
+ - Nouvelles fonctionnalités de connectivité et de casting
+ - Continuité fluide entre appareils distants
+ - Transitions sans coupure entre les morceaux
- Contrôle du fondu enchaîné
- - Nouvelle fonctionnalité de transitions personnalisées (uniquement pour les playlists)
- - Continuer la lecture après avoir fermé l\'application
- - Optimisations de l\'interface
- - Fonctionnalité de statistiques améliorée
- - Contrôle de la file d\'attente repensé avec plus de fonctionnalités
- - Support amélioré de différents types de fichiers pour la lecture et l\'édition de métadonnées
- - Contrôleur d\'autorisations amélioré
- - Corrections de bugs mineurs
-
+ - Nouvelle fonctionnalité de transitions personnalisées (playlists uniquement)
+ - Lecture continue après fermeture de l’application
+ - Optimisations de l’interface
+ - Amélioration des statistiques
+ - Contrôle de file d’attente repensé avec plus de fonctionnalités
+ - Meilleur support des types de fichiers et métadonnées
+ - Amélioration du gestionnaire de permissions
+ - Corrections mineures de bugs
+
+
- - Mise à jour de l\'interface Material 3 Expressive
- - Égaliseur 10 bandes & Effets
- - Nouveau flux de synchronisation de bibliothèque
- - Intégration IA (Modèles Gemini)
- - Import/Export playlist M3U
- - Intégration des pochettes d\'artistes Deezer
- - Pochettes de playlist personnalisées
- - Refonte de l\'architecture des paramètres
- - Animations file d\'attente & lecteur
- - Profils de référence & performances
- - Système de paroles amélioré avec décalage de synchronisation
-
+ - Mise à jour de l’interface Material 3 Expressive
+ - Égaliseur 10 bandes & effets
+ - Nouveau flux de synchronisation de la bibliothèque
+ - Intégration IA (modèles Gemini)
+ - Import/export de playlists M3U
+ - Intégration des images d’artistes Deezer
+ - Pochettes de playlists personnalisées
+ - Refonte de l’architecture des paramètres
+ - Animations du lecteur et de la file d’attente
+ - Optimisation des performances (Baseline Profiles)
+ - Meilleur système de paroles avec décalage synchronisé
+
+
- - Améliorations de la stabilité de la diffusion
- - Stabilité du panneau lecteur
- - Corrections de bugs générales & nettoyage
+ - Amélioration de la stabilité du casting
+ - Stabilité du lecteur améliorée
+ - Corrections générales de bugs
+
- - Le support Android Auto est maintenant disponible pour la lecture en voiture.
- - Le support Wear OS est actif, avec de meilleurs contrôles de lecture montre-téléphone.
- - Les intégrations cloud ont été élargies avec Telegram, NetEase, QQ Music et des améliorations Google Drive.
- - Écoutés récemment et restauration persistante de la file d\'attente gardent votre session d\'écoute prête.
- - Sauvegarde & Restauration v3 et outils de gestion de compte sont maintenant inclus.
- - Les paroles sont devenues plus intelligentes avec la recherche manuelle de secours et des améliorations de stockage.
-
+ - Support Android Auto pour la lecture en voiture.
+ - Support Wear OS avec meilleurs contrôles lecture montre-téléphone.
+ - Intégrations cloud étendues (Telegram, NetEase, QQ Music, Google Drive).
+ - Historique récent et restauration de file d’attente persistante.
+ - Outils de sauvegarde & restauration v3 et gestion de compte.
+ - Recherche de paroles améliorée avec fallback manuel.
+
+
- - Grande passe de performance sur le démarrage, la bibliothèque, la file d\'attente et les interactions avec le lecteur.
- - Les interfaces Lecteur, Diffusion, Paroles, Artiste et Genre ont été repensées pour une utilisation plus fluide.
- - Les flux de navigation et de recherche sont plus fiables, avec une gestion des routes plus sûre.
- - Compatibilité de lecture audio améliorée pour plus d\'appareils et de formats.
- - Les flux de sélection multiple ont été élargis aux titres, albums et playlists.
+ - Amélioration majeure des performances globales.
+ - Refonte des écrans lecteur, casting, paroles, artiste et genres.
+ - Navigation et recherche plus fiables.
+ - Compatibilité audio améliorée pour plus d’appareils.
+ - Meilleure prise en charge de la sélection multiple.
+
- - Le comportement de la file d\'attente et de l\'aléatoire est maintenant plus stable et prévisible.
- - Plusieurs cas limites de lecture en arrière-plan et de diffusion ont été corrigés.
- - Minuteur de sommeil, navigation de l\'onglet Fichiers et problèmes de crash artiste d\'album corrigés.
- - Le chargement du widget et la stabilité du service ont été améliorés pour réduire les problèmes de surchauffe/mémoire.
- - Corrections de bugs générales et peaufinage de l\'interface dans toute l\'application.
+ - Stabilité améliorée de la file d’attente et du mode aléatoire.
+ - Correction des problèmes de lecture en arrière-plan et casting.
+ - Correction du minuteur de sommeil et des crashs fichiers/artistes.
+ - Amélioration des widgets et stabilité des services.
+ - Corrections générales et polish UI.
+
- - Wear OS : Transfert de musique, lecture locale, synchronisation de la file d\'attente et contrôle à distance depuis la montre.
- - IA : Intégration de Groq AI et OpenRouter (expérimental) avec optimisation des jetons.
- - Cloud : Ajout du support de Jellyfin.
- - Paroles : Traduction synchronisée avec interrupteur dédié, support du format Kugou LRC, personnalisation de l\'alignement du texte et amélioration du chargement à distance.
- - UI/UX : Mode barre de navigation compacte, thèmes dynamiques depuis la palette des pochettes d\'album, défilement pour les titres longs et nouvelles options de tri.
- - Telegram : Support natif des sujets et modes d\'affichage améliorés.
-
+ - Wear OS : transfert de musique, lecture locale, synchronisation de file et contrôle à distance.
+ - IA : intégration Groq AI et OpenRouter (expérimental).
+ - Cloud : ajout du support Jellyfin.
+ - Paroles : traduction synchronisée, support Kugou LRC, alignement et chargement amélioré.
+ - UI/UX : barre compacte, thèmes dynamiques, titre défilant, tri amélioré.
+ - Telegram : support des topics natifs.
+
+
- - Moteur audio : Refonte complète avec support de plus de formats (MIDI, ALAC, M4A) et optimisation des décodeurs.
- - Efficacité : Réduction drastique de la consommation d\'énergie, corrections de la surchauffe et optimisation des tâches en arrière-plan (SyncWorker).
- - Base de données : Optimisations massives des requêtes et refonte du cache des pochettes pour éviter la perte de données.
- - Démarrage : Temps de chargement amélioré grâce à l\'optimisation des profils de référence (Baseline Profile).
+ - Moteur audio entièrement refait avec meilleurs formats (MIDI, ALAC, M4A).
+ - Réduction drastique de la consommation énergétique.
+ - Optimisation des bases de données et du cache.
+ - Amélioration du temps de démarrage via Baseline Profiles.
+
- - Lecture : Correction des saccades en Opus/MP3, erreurs de ReplayGain pendant les fondus enchaînés et problèmes de démarrage sur les décodeurs Samsung.
- - Stabilité : Élimination des plantages au démarrage, de la navigation par artiste et sur les appareils Android 12+.
- - Interface : Correction du clignotement des pochettes, du dépassement de texte pour les scripts non latins et du comportement de la barre de navigation/du mini-lecteur.
- - Sécurité : Sécurisation renforcée de la gestion des identifiants, des autorisations de stockage et de la communication avec le serveur multimédia.
+ - Correction des problèmes de lecture et de stuttering.
+ - Correction des crashs sur Android et Samsung decoders.
+ - Correction des problèmes UI (cover art, texte, navigation).
+ - Sécurisation du stockage et des communications médias.
+
- - Traduction : Espagnol, Français, Russe, Chinois simplifié, Indonésien, Italien
-
-
\ No newline at end of file
+ - Localisation : espagnol, français, russe, chinois simplifié, indonésien, italien.
+
+
+
+ - Intégration Google Drive avec gestion de lecture.
+ - Édition en lot des métadonnées.
+ - Traduction IA des paroles avec options Wear OS.
+ - Outil de diagnostic de latence et sélection multiple dans la recherche.
+ - Support arabe et turc avec options réseau local HTTP.
+
+
+
+ - Économie de batterie drastique.
+ - Optimisation de la gestion de file d’attente.
+ - Animations Material 3 Expressive améliorées.
+ - Synchronisation bibliothèque optimisée.
+
+
+
+ - Correction des problèmes de lecture et buffering.
+ - Correction de la suppression de chansons externes.
+ - Correction des crashs et problèmes mémoire Wear OS et mobile.
+
+
+
diff --git a/app/src/main/res/values-fr/strings_home_screen.xml b/app/src/main/res/values-fr/strings_home_screen.xml
index 4b9fd3019..ceb04518a 100644
--- a/app/src/main/res/values-fr/strings_home_screen.xml
+++ b/app/src/main/res/values-fr/strings_home_screen.xml
@@ -9,9 +9,9 @@
Diffusez de la musique depuis vos comptes cloud
- Bêta 0.7.0
+ Bêta 0.7.5
β
- Bienvenue dans PixelPlayer 0.7.0-bêta
+ Bienvenue dans PixelPlayer 0.7.5-bêta
Vous utilisez une version bêta qui peut contenir des bugs, des plantages ou des fonctionnalités expérimentales. Aidez-nous à nous améliorer en signalant les problèmes.
À quoi s\'attendre
Des bugs, des plantages ou des fonctionnalités incomplètes peuvent survenir de manière inattendue.
@@ -274,4 +274,4 @@
%1$d chanson
%1$d chansons
Semaine %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-fr/strings_player.xml b/app/src/main/res/values-fr/strings_player.xml
index 674747df4..da510f96e 100644
--- a/app/src/main/res/values-fr/strings_player.xml
+++ b/app/src/main/res/values-fr/strings_player.xml
@@ -129,8 +129,8 @@
Paroles
Chargement des paroles…
- Synchronisées
- Statiques
+ Synchronisé
+ Statique
Options des paroles
−.5
−.1
diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml
index 13f14c868..06261a94e 100644
--- a/app/src/main/res/values-fr/strings_settings.xml
+++ b/app/src/main/res/values-fr/strings_settings.xml
@@ -170,6 +170,7 @@
Coréen
Norvégien (Bokmål)
Turc
+ Japonais
Thème de l\'application
Passer du mode clair au mode sombre, ou suivre l\'apparence du système.
Thème clair
@@ -629,8 +630,17 @@
Contributeurs open source
Liste des contributeurs en direct de GitHub.
%1$d contrib.
+ GitHub
+ Dépôt
+ Telegram
+ Support
+ Ouvrir le dépôt GitHub
+ Rejoindre la communauté Telegram
Ouvrir le profil GitHub
Ouvrir Telegram
Avatar de %1$s
Icône de %1$s
+ Volume
+ Mettre en pause quand le volume atteint zéro
+ Mettre automatiquement en pause la lecture lorsque le volume est à 0
diff --git a/app/src/main/res/values-in/strings_home_screen.xml b/app/src/main/res/values-in/strings_home_screen.xml
index 0af328bde..7a5e0de11 100644
--- a/app/src/main/res/values-in/strings_home_screen.xml
+++ b/app/src/main/res/values-in/strings_home_screen.xml
@@ -9,9 +9,9 @@
Alirkan musik dari akun cloud Anda
- Beta 0.7.0
+ Beta 0.7.5
β
- Selamat datang di PixelPlayer 0.7.0-beta
+ Selamat datang di PixelPlayer 0.7.5-beta
Anda menggunakan versi beta yang mungkin berisi bug, crash, atau fitur eksperimental. Bantu kami meningkatkannya dengan melaporkan masalah.
Yang perlu diharapkan
Bug, crash, atau fitur yang belum selesai dapat terjadi sewaktu-waktu.
@@ -274,4 +274,4 @@
%1$d Lagu
%1$d Lagu
Minggu %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml
index 6aab6877b..c9dabb593 100644
--- a/app/src/main/res/values-in/strings_settings.xml
+++ b/app/src/main/res/values-in/strings_settings.xml
@@ -170,6 +170,7 @@
Korea
Norwegia (Bokmål)
Turki
+ Jepang
Tema Aplikasi
Beralih antara terang, gelap, atau ikuti tampilan sistem.
Tema Terang
@@ -629,8 +630,17 @@
Kontributor open source
Daftar kontributor langsung dari GitHub.
%1$d kontrib.
+ GitHub
+ Repositori
+ Telegram
+ Dukungan
+ Buka repositori GitHub
+ Bergabung dengan komunitas Telegram
Buka profil GitHub
Buka Telegram
Avatar %1$s
Ikon %1$s
+ Volume
+ Jeda saat volume mencapai nol
+ Otomatis menjeda pemutaran saat volume diatur ke 0
diff --git a/app/src/main/res/values-it/strings_changelogs.xml b/app/src/main/res/values-it/strings_changelogs.xml
index bc4f2f71b..d7ce29f70 100644
--- a/app/src/main/res/values-it/strings_changelogs.xml
+++ b/app/src/main/res/values-it/strings_changelogs.xml
@@ -1,132 +1,170 @@
- Changelog
- Visualizza su GitHub
+ Registro modifiche
+ Vedi su GitHub
Miglioramenti
Correzioni
Novità
- Aggiunto
+ Aggiunti
+
- - Supporto Chromecast per trasmettere l\'audio dal tuo dispositivo.
- - Changelog in-app per tenerti aggiornato sulle ultime funzioni.
+ - Supporto Chromecast per trasmettere audio dal dispositivo.
+ - Changelog integrato per tenerti aggiornato sulle ultime funzionalità.
- Supporto per file .LRC, sia incorporati che esterni.
- - Supporto per i testi offline.
- - Testi sincronizzati (sincronizzati con il brano).
+ - Supporto per testi offline.
+ - Testi sincronizzati con la musica.
- Nuova schermata per visualizzare la coda completa.
- - Riordina e rimuovi brani dalla coda.
- - Gesti del mini-riproduttore (scorrimento verso il basso per chiudere).
- - Aggiunte altre animazioni Material.
- - Nuove impostazioni per personalizzare l\'aspetto e lo stile.
- - Nuove impostazioni per cancellare la cache.
+ - Riordino e rimozione dei brani dalla coda.
+ - Gesti del mini-player (scorri verso il basso per chiudere).
+ - Aggiunte nuove animazioni Material.
+ - Nuove impostazioni per personalizzare l’aspetto.
+ - Nuova impostazione per svuotare la cache.
+
- - Riprogettazione completa dell\'interfaccia utente.
- - Riprogettazione completa del riproduttore.
- - Miglioramenti delle prestazioni nella libreria.
- - Velocità di avvio dell\'applicazione migliorata.
- - L\'IA ora fornisce risultati migliori.
+ - Restyling completo dell’interfaccia utente.
+ - Restyling completo del player.
+ - Miglioramento delle prestazioni della libreria.
+ - Miglioramento della velocità di avvio dell’app.
+ - L’IA ora fornisce risultati migliori.
+
- - Corretti vari bug nell\'editor dei tag.
- - Corretto un bug per cui la notifica di riproduzione non si cancellava.
- - Corretti diversi bug che causavano il crash dell\'app.
+ - Corretti vari bug nell’editor dei tag.
+ - Corretto un bug per cui la notifica di riproduzione non veniva rimossa.
+ - Corretti diversi bug che causavano crash dell’app.
+
- - Introdotto un hub di statistiche di ascolto più ricco con approfondimenti dettagliati sulle tue sessioni.
- - Lanciato un lettore rapido fluttuante per aprire e visualizzare in anteprima istantaneamente i file locali.
- - Aggiunta una scheda cartelle con navigazione ad albero e vista predisposta per le playlist.
+ - Introdotto un hub statistiche di ascolto più ricco con analisi approfondite.
+ - Rilasciato un mini-player flottante per aprire e visualizzare file locali.
+ - Aggiunta scheda cartelle con navigazione ad albero e vista playlist.
+
- - Raffinata l\'interfaccia utente generale Material 3 per un\'esperienza più pulita e coesa.
- - La modifica dei metadati ora supporta il cambio della copertina.
- - Animazioni e transizioni più fluide in tutta l\'app per una navigazione più fluida.
- - Migliorato il layout della schermata dell\'artista con dettagli più ricchi e rifiniture.
- - Aggiornata la generazione di DailyMix e YourMix con selezioni più intelligenti e varie.
- - Potenziata la generazione di playlist tramite IA.
- - Migliorata la rilevanza e la presentazione dei risultati di ricerca per una scoperta più rapida.
- - Esteso il supporto a una gamma più ampia di formati di file audio.
-
+ - Interfaccia Material 3 migliorata per un’esperienza più coerente.
+ - Modifica metadati ora supporta la copertina album.
+ - Animazioni e transizioni più fluide in tutta l’app.
+ - Migliorata la schermata artista con più dettagli.
+ - Migliorati DailyMix e YourMix con selezioni più varie.
+ - Potenziata la generazione playlist IA.
+ - Ricerca migliorata per risultati più rapidi.
+ - Supporto esteso a più formati audio.
+
+
- - Risolti i problemi con i metadati in modo che i dettagli dei brani rimangano accurati ovunque.
- - Ripristinate le scorciatoie delle notifiche in modo che ritornino in modo affidabile alla riproduzione.
+ - Risolti problemi di incoerenza nei metadati.
+ - Ripristinati i collegamenti rapidi alle notifiche.
+
- - Importante riprogettazione della navigazione
- - Nuovo esploratore di file per scegliere le directory sorgente
- - Nuove funzionalità di connettività e trasmissione (casting)
- - Continuità perfetta tra dispositivi remoti
- - Transizione senza interruzioni (gapless) tra i brani
- - Controllo della dissolvenza incrociata (crossfade)
- - Nuova funzione di transizioni personalizzate (solo per playlist)
- - Continua la riproduzione anche dopo la chiusura dell\'app
- - Ottimizzazioni dell\'interfaccia utente
- - Funzionalità di statistica migliorata
- - Controllo della coda riprogettato con più funzioni
- - Supporto migliorato per diversi tipi di file per la riproduzione e la modifica dei metadati
- - Gestione dei permessi migliorata
- - Correzioni di bug minori
-
+ - Grande rinnovamento della navigazione
+ - Nuovo file explorer per scegliere le cartelle sorgente
+ - Nuove funzionalità di connettività e casting
+ - Continuità fluida tra dispositivi remoti
+ - Transizioni senza interruzioni tra i brani
+ - Controllo crossfade
+ - Nuove transizioni personalizzate (solo playlist)
+ - Riproduzione continua dopo la chiusura dell’app
+ - Ottimizzazioni UI
+ - Miglioramento statistiche
+ - Controllo coda riprogettato con più funzioni
+ - Migliorato supporto file e metadati
+ - Migliorato sistema permessi
+ - Correzioni minori di bug
+
+
- - Aggiornamento dell\'interfaccia utente Material 3 Expressive
- - Equalizzatore a 10 bande ed effetti
- - Nuovo flusso di sincronizzazione della libreria
+ - Aggiornamento Material 3 Expressive UI
+ - Equalizzatore a 10 bande & effetti
+ - Nuovo flusso di sincronizzazione libreria
- Integrazione IA (modelli Gemini)
- - Importazione/esportazione di playlist M3U
- - Integrazione delle immagini degli artisti da Deezer
- - Copertine personalizzate per le playlist
- - Rifattorizzazione dell\'architettura delle impostazioni
- - Animazioni per la coda e il riproduttore
- - Profili Baseline e prestazioni
- - Sistema di testi migliorato con regolazione del ritardo di sincronizzazione
-
+ - Import/Export playlist M3U
+ - Integrazione artwork artisti Deezer
+ - Copertine playlist personalizzate
+ - Refactor architettura impostazioni
+ - Animazioni player e coda
+ - Ottimizzazione prestazioni (Baseline Profiles)
+ - Sistema testi migliorato con offset sincronizzato
+
+
- - Miglioramenti della stabilità della trasmissione (casting)
- - Stabilità del pannello del riproduttore
- - Correzioni di bug generali e pulizia
+ - Migliorata stabilità del casting
+ - Migliorata stabilità del player
+ - Correzioni generali di bug
+
- - Il supporto Android Auto è ora disponibile per la riproduzione in auto.
- - Il supporto Wear OS è attivo, inclusi migliori controlli di riproduzione tra smartwatch e telefono.
- - Integrazioni cloud ampliate con miglioramenti per Telegram, NetEase, QQ Music e Google Drive.
- - La sezione Ascoltati di recente e il ripristino persistente della coda mantengono pronta la sessione di ascolto.
- - Ora sono inclusi il Backup e Ripristino v3 e gli strumenti di gestione dell\'account.
- - I testi sono diventati più intelligenti con ricerca manuale alternativa e miglioramenti di archiviazione.
-
+ - Supporto Android Auto per la riproduzione in auto.
+ - Supporto Wear OS con controlli migliorati tra orologio e telefono.
+ - Integrazioni cloud estese (Telegram, NetEase, QQ Music, Google Drive).
+ - Ripristino cronologia recente e coda persistente.
+ - Backup & Restore v3 e gestione account.
+ - Ricerca testi migliorata con fallback manuale.
+
+
- - Grande ottimizzazione delle prestazioni per avvio, libreria, coda e interazioni con il riproduttore.
- - Le schermate di Riproduttore, Cast, Testi, Artista e Genere sono state riprogettate per un uso più fluido.
- - I flussi di navigazione e ricerca sono più affidabili, con una gestione dei percorsi più sicura.
- - Compatibilità della riproduzione audio migliorata per più dispositivi e formati.
- - I flussi di selezione multipla sono stati estesi a brani, album e playlist.
+ - Grande miglioramento delle prestazioni globali.
+ - Restyling di player, casting, testi, artista e generi.
+ - Navigazione e ricerca più affidabili.
+ - Compatibilità audio migliorata.
+ - Migliorato supporto selezione multipla.
+
- - Il comportamento della coda e della riproduzione casuale è ora più stabile e prevedibile.
- - Risolti diversi casi limite per la trasmissione e la riproduzione in background.
- - Risolti i problemi relativi a timer di spegnimento, navigazione della scheda File e crash dell\'artista dell\'album.
- - Migliorata la stabilità del servizio e il caricamento dei widget per ridurre i problemi di surriscaldamento e memoria.
- - Correzioni di bug generali e rifiniture dell\'interfaccia utente in tutta l\'app.
+ - Migliorata stabilità coda e shuffle.
+ - Corretti problemi di riproduzione in background e casting.
+ - Corretti crash su sleep timer e file/artisti.
+ - Migliorati widget e stabilità servizi.
+ - Correzioni generali UI.
+
- - Wear OS: Trasferimento musica, riproduzione locale, sincronizzazione della coda e controllo remoto da smartwatch.
- - IA: Integrazione di Groq AI e OpenRouter (sperimentale) con ottimizzazione dei token.
- - Cloud: Aggiunto il supporto a Jellyfin.
- - Testi: Traduzione sincronizzata con interruttore dedicato, supporto per il formato Kugou LRC, personalizzazione dell\'allineamento del testo e caricamento remoto migliorato.
- - UI/UX: Modalità barra di navigazione compatta, temi dinamici dalla tavolozza dei colori dell\'album, testo scorrevole per titoli lunghi e nuove opzioni di ordinamento.
- - Telegram: Supporto nativo per gli argomenti (topics) e modalità di visualizzazione migliorate.
-
+ - Wear OS: trasferimento musica, riproduzione locale, sync coda e controllo remoto.
+ - IA: integrazione Groq AI e OpenRouter (sperimentale).
+ - Cloud: aggiunto supporto Jellyfin.
+ - Testi: traduzione sincronizzata, supporto Kugou LRC, allineamento migliorato.
+ - UI/UX: barra compatta, temi dinamici, marquee, nuovi ordinamenti.
+ - Telegram: supporto topic nativi.
+
+
- - Motore audio: Revisione completa con supporto per più formati (MIDI, ALAC, M4A) e ottimizzazione dei decodificatori.
- - Efficienza: Riduzione drastica del consumo energetico, risoluzione del surriscaldamento e ottimizzazione delle attività in background (SyncWorker).
- - Database: Ottimizzazioni massicce delle query e cache delle copertine riprogettata per prevenire la perdita di dati.
- - Avvio: Tempo di caricamento migliorato tramite l\'ottimizzazione di Baseline Profile.
+ - Motore audio completamente rifatto con supporto ampliato (MIDI, ALAC, M4A).
+ - Ridotto drasticamente consumo energetico.
+ - Ottimizzate query database e cache artwork.
+ - Migliorato tempo di avvio tramite Baseline Profiles.
+
- - Riproduzione: Risolti i micro-scatti con Opus/MP3, gli errori ReplayGain durante le dissolvenze incrociate e i problemi di avvio sui decodificatori Samsung.
- - Stabilità: Eliminati i crash all\'avvio, nella navigazione dell\'artista e sui dispositivi con Android 12+.
- - Interfaccia utente: Corretto lo sfarfallio delle copertine, il superamento dei limiti di testo per le scritture non latine e il comportamento della barra di navigazione/mini-riproduttore.
- - Sicurezza: Maggiore protezione nella gestione delle credenziali, dei permessi di archiviazione e della comunicazione con il server multimediale.
+ - Corretti problemi di stuttering e riproduzione.
+ - Corretti crash su Android e decoder Samsung.
+ - Corretti problemi UI (copertine, testo, navigazione).
+ - Migliorata sicurezza storage e comunicazione media.
+
- - Localizzazione: Spagnolo, Francese, Russo, Cinese semplificato, Indonesiano, Italiano
-
-
\ No newline at end of file
+ - Localizzazione: spagnolo, francese, russo, cinese semplificato, indonesiano, italiano.
+
+
+
+ - Integrazione Google Drive con gestione riproduzione.
+ - Modifica batch dei metadati.
+ - Traduzione testi IA con opzioni Wear OS.
+ - Strumento diagnostica lag e selezione multipla ricerca.
+ - Supporto arabo e turco con opzioni rete locale HTTP.
+
+
+
+ - Risparmio batteria drastico.
+ - Ottimizzazione gestione coda.
+ - Animazioni Material 3 Expressive migliorate.
+ - Sync libreria ottimizzato.
+
+
+
+ - Corretti problemi di buffering e riproduzione.
+ - Corretto sync eliminazione brani esterni.
+ - Corretti crash e problemi memoria su Wear OS e mobile.
+
+
+
diff --git a/app/src/main/res/values-it/strings_home_screen.xml b/app/src/main/res/values-it/strings_home_screen.xml
index 51cd56f73..c53d26ccf 100644
--- a/app/src/main/res/values-it/strings_home_screen.xml
+++ b/app/src/main/res/values-it/strings_home_screen.xml
@@ -9,9 +9,9 @@
Riproduci in streaming la musica dai tuoi account cloud
- Beta 0.7.0
+ Beta 0.7.5
β
- Benvenuto in PixelPlayer 0.7.0-beta
+ Benvenuto in PixelPlayer 0.7.5-beta
Stai utilizzando una build beta che potrebbe contenere bug, crash o funzionalità sperimentali. Aiutaci a migliorare segnalando i problemi.
Cosa aspettarsi
Bug, crash o funzionalità incomplete possono verificarsi inaspettatamente.
@@ -274,4 +274,4 @@
%1$d brano
%1$d brani
Settimana %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml
index 234a0e911..e464867fa 100644
--- a/app/src/main/res/values-it/strings_settings.xml
+++ b/app/src/main/res/values-it/strings_settings.xml
@@ -174,6 +174,7 @@
Coreano
Norvegese (Bokmål)
Turco
+ Giapponese
Tema app
Passa tra chiaro, scuro o segui l\'aspetto di sistema.
Tema chiaro
@@ -633,8 +634,17 @@
Contributori open source
Lista contributori live da GitHub.
%1$d contrib.
+ GitHub
+ Repository
+ Telegram
+ Supporto
+ Apri repository GitHub
+ Unisciti alla community di Telegram
Apri profilo GitHub
Apri Telegram
Avatar di %1$s
Icona di %1$s
+ Volume
+ Metti in pausa quando il volume raggiunge zero
+ Metti automaticamente in pausa la riproduzione quando il volume è 0
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
new file mode 100644
index 000000000..5dedb3c73
--- /dev/null
+++ b/app/src/main/res/values-ja/strings.xml
@@ -0,0 +1,128 @@
+
+
+ PixelPlayer
+ 音楽プレイヤー
+ アプリ名の変更について
+ 商標上の理由により、アプリ名を PixelPlay から PixelPlayer に変更しました。引き続きお楽しみください!
+ 今後表示しない
+
+
+ ホーム
+ 検索
+ ライブラリ
+
+
+ 特別な権限が必要です
+ 曲のメタデータ(.mp3 ファイル)を編集するには、PixelPlayer にすべてのファイルへの特別なアクセス権限が必要です。これにより、トラックのタグを直接変更できます。メタデータ編集を有効にするには、次の画面でこの権限を許可してください。
+ 権限を許可
+
+
+ すぐに再生
+ このオーディオファイルを開けませんでした。
+ フルプレイヤーを開く
+
+
+ シャッフル
+ すべての曲をシャッフル
+ すべてシャッフル
+ 最後のプレイリスト
+ 開けるプレイリストがありません
+
+
+ Play ストアを開く
+ ベータを続ける
+ Play ストアのリンクは GitHub の設定から有効化されます。
+ PixelPlayer が Google Play で公開されました
+ リリース更新は Google Play の安定版チャンネルをご利用ください。ベータビルドも引き続き提供されます。
+ PixelPlayer
+ リリースのお知らせ
+ 近日公開
+
+
+ PixelPlayer をご利用いただきありがとうございます!
+ ハイスコア %1$d
+ 閉じる
+ スコア
+ レベル %1$d
+ ライフ
+ レベルクリア!
+ ゲームオーバー
+ スコア: %1$d
+ もう一度?
+ 次のレベル
+ ゲームを再起動
+ タップして再起動
+ ランダムに音楽を再生
+ ブロック崩し
+ ハイスコア %1$d
+ プレイ
+ ドラッグしてパドルを動かす
+
+
+ プレイヤーを閉じる
+ 再生操作を処理中…
+ 再生エラー: %1$s
+
+
+ 戻る
+ OK
+ キャンセル
+ 閉じる
+ エラー
+ 検索
+ 検索をクリア
+ すべて
+ 確認
+ 保存しました!
+ 選択済み
+ %1$d%%
+ アーティスト
+ すべて選択
+ クリア
+ 不明なエラー
+
+
+ 保存
+ 完了
+ リセット
+ 適用
+ シャッフル
+ コピー
+ 共有
+ 元に戻す
+ インポート
+ 削除
+ エクスポート
+ 結合
+ 名前を変更
+ 作成
+ 歌詞
+ 設定
+ アルバムアート
+ プレイリスト
+ 不明なトラック
+ 不明なアーティスト
+ 不明なアルバム
+ 閉じる
+ 追加
+ 削除
+ 再生
+ 前のトラック
+ 次のトラック
+ お気に入り
+ 一時停止
+ リピート
+ オプション
+ シャッフル再生
+ %1$s のその他のオプション
+ メニューを展開
+ 次へ
+ 完了
+ デフォルトに戻す
+ すべてエクスポート
+ すべて結合
+ すべて共有
+ アルバムを再生
+ アルバムをシャッフル再生
+ %1$s のアルバムアート
+
diff --git a/app/src/main/res/values-ja/strings_changelogs.xml b/app/src/main/res/values-ja/strings_changelogs.xml
new file mode 100644
index 000000000..85544d5ba
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_changelogs.xml
@@ -0,0 +1,9 @@
+
+
+ 変更履歴
+ GitHub で見る
+ 改善
+ 修正
+ 新機能
+ 追加
+
diff --git a/app/src/main/res/values-ja/strings_cloud_services.xml b/app/src/main/res/values-ja/strings_cloud_services.xml
new file mode 100644
index 000000000..a2e5bac75
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_cloud_services.xml
@@ -0,0 +1,226 @@
+
+
+
+ Telegram ログイン
+ 番号を編集中です。再送すると前のコードが無効になります。
+ 処理中…
+ Telegram を初期化中…
+ ログアウト中…
+ セッションを閉じています…
+ セッションが閉じました。続けるにはログインを再度開いてください。
+ 安全な Telegram セッションを準備中…
+ Telegram からの応答を待機中…
+ Telegram に接続
+ Telegram に接続してチャンネルやチャットから音楽をストリーミングします。
+ 電話番号
+ Telegram の番号を入力してください。後で戻って編集することもできます。
+ 電話番号
+ 81
+ 09012345678
+ コードを送信
+ 確認コード
+ Telegram からのコードを入力してください。番号が間違っている場合は戻って修正してください。
+ コード
+ 12345
+ 電話番号を編集
+ コードを再送
+ コードを確認
+ 二段階認証パスワード
+ Telegram のパスワードを入力してください。番号を修正するために戻ることもできます。
+ パスワード
+ パスワードを確認
+ しばらくお待ちください…
+
+
+ Telegram チャンネル
+ チャンネルを追加
+ Telegram パブリックチャンネル
+ 同期中
+ 今すぐ同期
+ トピックを折りたたむ
+ トピックを表示
+ チャンネルオプション
+ トピック
+ チャンネルを同期中
+ Telegram から曲を更新中
+ このチャンネルから最新の曲を取得
+ チャンネルを削除
+ 同期を停止してキャッシュされた曲を削除
+ チャンネルを削除しますか?
+ %1$s の同期が停止し、このチャンネルのキャッシュされた曲がすべて削除されます。
+ 削除
+ 同期済みチャンネルがありません
+ Telegram のパブリックチャンネルを追加して\n音楽ライブラリを同期しましょう
+ チャンネルを追加
+ 未同期
+ %1$s に同期
+
+
+ チャンネルを追加
+ 音楽を同期する Telegram パブリックチャンネルを検索
+ \@チャンネル名またはリンク
+ 検索中…
+ チャンネルを検索
+ パブリックチャンネルのユーザー名またはリンクを入力して\nオーディオファイルを同期してください
+
+
+ - %d 曲
+
+
+ - %d トピック
+
+
+
+ Subsonic
+ Navidrome、Airsonic などの Subsonic 互換サーバーを管理します。
+
+
+ 同期をタップして Jellyfin のプレイリストを取得してください
+ Jellyfin サーバーの接続を管理します。
+
+
+ 音楽フォルダ
+ + をタップして Drive フォルダを追加
+ フォルダがまだ追加されていません
+ %1$d フォルダが同期済み
+ フォルダを追加
+
+
+ プレイリストの種類を選択
+ 同期するプレイリストを選択:
+ すべてのプレイリスト
+ 作成 & お気に入り
+ 作成したプレイリスト
+ お気に入りのプレイリスト
+
+
+ %1$d プレイリストが同期済み
+ プレイリスト
+ 同期
+ まだプレイリストが同期されていません
+ 同期をタップしてプレイリストを取得してください
+ クイックアクション
+ ライブラリを同期
+ 切断
+ %1$d 曲
+
+
+ 同期中
+ ライブラリを同期中…
+ プレイリストを取得中…
+ プレイリストを同期中: %1$s
+ ローカルライブラリを更新中…
+ 同期完了
+ アルバムリストを取得中…
+ %1$s から曲を取得中…
+ %1$d 曲をデータベースに保存中…
+ ライブラリに曲が見つかりません
+ ライブラリ同期完了
+ 同期中…
+ エラー: %1$s
+
+
+ 同期
+ すべて同期
+ ログアウト
+ すべてのプレイリストを同期
+ ユーザーアバター
+
+
+ インターネット接続がありません
+ このコンテンツにはインターネット接続が必要です。ネットワーク設定を確認して再試行してください。
+ オフラインです
+ このコンテンツにアクセスするにはインターネット接続を確認して再試行してください。
+
+
+ 接続
+ 接続中…
+ サーバー URL とアカウントの認証情報を入力してください。
+ 接続詳細
+ パスワードを非表示
+ パスワード
+ パスワードを入力
+ http:// を入力
+ サーバー URL
+ パスワードを表示
+ Telegram
+ ユーザー名
+ admin
+ ようこそ、%1$s!
+
+
+ Navidrome、Gonic、Airsonic などの Subsonic 互換サーバーに対応
+ Navidrome、Airsonic、Gonic、Ampache などの Subsonic API 互換サーバーをサポートします。
+ サーバーが対応している場合はアプリパスワードも使用できます。
+ https:// を入力
+ セルフホスト型音楽サーバーに接続
+ Navidrome
+ サーバーの完全な https:// ベースアドレスを使用してください。
+ https://music.example.com
+ Subsonic または Navidrome のアカウント名です。
+ Subsonic / Navidrome
+ Subsonic
+
+
+ Jellyfin サーバー URL とアカウントの認証情報を入力してください。
+ 音楽ライブラリをストリーミングするために Jellyfin サーバーに接続します
+ Jellyfin サーバーに接続します。ローカルネットワークアクセスには HTTP と HTTPS の両方がサポートされています。
+ Jellyfin
+ Jellyfin アカウントのパスワード。
+ Jellyfin メディアサーバーに接続
+ Jellyfin
+ ポートを含む Jellyfin サーバーの完全な URL。
+ http://192.168.1.100:8096
+ Jellyfin アカウントのユーザー名。
+
+
+ Google Drive から直接音楽ファイルをストリーミング
+ Google Drive に接続
+ Google Drive に接続しました!
+ 「PixelPlayer Music」を作成
+ ここに音楽用の新しいフォルダを作成
+ フォルダがありません
+ フォルダを開く
+ 音楽ソースとして使用するフォルダを選択または作成
+ 音楽フォルダを選択
+ Google Drive をセットアップ中…
+ Google でサインイン
+ Google Drive
+ 使用
+
+
+ セッション Cookie を読み取れませんでした。
+ 完了
+ 終了
+ Cookie が見つかりません。先にログインしてください。
+ ページの読み込みに時間がかかっています。更新するか別のネットワークをお試しください。
+ +
+ 保存中…
+ 残る
+ ページの読み込みがタイムアウトしました。進捗を失わずに再試行できます。
+ Web で戻る
+ 後で戻れます。閉じると現在のページの状態は破棄されます。
+ Web で進む
+ 更新
+ 再試行
+ ホームを開く
+ WebView の読み込みに失敗しました。
+
+
+ NetEase の Cookie を読み取れませんでした: %1$s
+ NetEase のログインを終了しますか?
+ NetEase の読み込み中に HTTP %1$d エラーが発生しました。
+ まだログインが検出されていません。完了を押す前に NetEase のログインを完了してください。
+ NetEase Music にログイン
+ セキュリティについて: パスワードは NetEase のウェブページにのみ入力されます。PixelPlayer はライブラリを同期するためにセッション Cookie(MUSIC_U)を保存します。
+ NetEase Music
+
+
+ QQ Music の Cookie を読み取れませんでした: %1$s
+ QQ Music のログインを終了しますか?
+ QQ Music の読み込み中に HTTP %1$d エラーが発生しました。
+ まだログインが検出されていません。完了を押す前に QQ Music のログインを完了してください。
+ QQ Music にログイン
+ セキュリティについて: パスワードは QQ Music のウェブページにのみ入力されます。PixelPlayer はライブラリを同期するためにセッション Cookie を保存します。
+ QQ Music
+
diff --git a/app/src/main/res/values-ja/strings_equalizer.xml b/app/src/main/res/values-ja/strings_equalizer.xml
new file mode 100644
index 000000000..3c36811ad
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_equalizer.xml
@@ -0,0 +1,57 @@
+
+
+
+ 名前を入力してください
+ 名前を変更
+
+
+ 表示モードを変更
+ イコライザーを無効化
+ イコライザーを有効化
+ 編集
+ プリセットを編集
+ カスタムプリセット
+ プリセット
+ 更新
+ バスブースト
+ バーチャライザー
+ ラウドネス
+ 非対応
+ この端末では非対応
+ 音量
+ 周波数特性
+ Hz
+ バス
+ ローミッド
+ ハイミッド
+ トレブル
+ バス / ロー
+ ミッド / ハイ
+ ページ %1$d
+ 時間をリセット
+ 新規保存
+
+
+ 保存済みプリセット
+ カスタムプリセットがまだ保存されていません。
+ ピンを外す
+ ピン留め
+ 名前を変更
+ 削除
+
+
+ カスタムプリセットを保存
+ カスタムイコライザープリセットの名前を入力してください。
+ プリセット名
+ プリセット名を変更
+
+
+ プリセットを管理
+ ドラッグして並び替え • 目のアイコンで表示/非表示を切り替え
+ 並び替え
+ プリセットをリセット
+ デフォルトのプリセット順と表示状態に戻します。続けますか?
+ デフォルトに戻す
+ 表示
+ 非表示
+
diff --git a/app/src/main/res/values-ja/strings_home_screen.xml b/app/src/main/res/values-ja/strings_home_screen.xml
new file mode 100644
index 000000000..ac2021f3e
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_home_screen.xml
@@ -0,0 +1,276 @@
+
+
+
+ β
+ ベータ
+ クラウドストリーミング
+ 変更履歴
+ クラウドストリーミング
+ クラウドアカウントから音楽をストリーミング
+
+
+ Beta 0.7.0
+ β
+ PixelPlayer 0.7.0-beta へようこそ
+ バグ、クラッシュ、または試験的な機能が含まれている可能性があるベータビルドを使用しています。問題を報告して改善にご協力ください。
+ 期待されること
+ バグ、クラッシュ、または未完成の機能が予期せず発生することがあります。
+ 一部の機能は予告なく変更または削除される場合があります。
+ ベータビルドはリリース版より不安定な場合があります。
+ 既知の問題を報告する前に必ず最新版を確認してください。
+ テスト中にベータビルドが変更、破損、または改善される可能性があること。
+ GitHub Issue のショートカット
+ まず検索してから、バグ、クラッシュ、要望、質問に対する集中したレポートを作成してください。
+ 既存の Issue を開く
+ Issue またはクラッシュを報告
+ 再現手順、期待される結果、実際の結果、デバイス/OS の詳細を共有してください。
+ 報告方法
+ 新しい Issue を開く前の簡単なチェックリスト。
+ Issue を開く前に
+ 重複を避けるために既存のオープンおよびクローズ済みの Issue を検索してください。
+ 最新の PixelPlayer バージョンに更新して問題が引き続き発生することを確認してください。
+ アプリを再起動して問題が続くことを確認してください。
+ 再現を試みて正確な手順を書き留めてください。
+ Issue の種類は?
+ バグ報告: 何かが正しく動作しない。
+ 機能リクエスト: 新機能や改善の追加。
+ 質問: Discussions が有効な場合はそちらを使用するか、question ラベルで Issue を開いてください。
+ バグ報告
+ 何かが正しく動作しないまたはクラッシュする場合にこれらのフィールドをコピーしてください。
+ バグ報告
+ 概要:
+ 期待される動作:
+ 現在の動作:
+ 再現手順: 1. 2. 3.
+ 頻度は? 常時 / 時々 / まれに。
+ スクリーンショット / 動画: あれば。
+ ログ / スタックトレース: あれば。
+ 環境
+ PixelPlayer バージョン:
+ インストール元: GitHub リリース、デバッグビルド、ナイトリービルドなど。
+ Android バージョン:
+ 端末モデル:
+ 補足情報: SD カードの使用、特別な設定、権限など。
+ 機能リクエスト
+ 新機能や改善を要望する場合にこれらのフィールドをコピーしてください。
+ 問題の説明: 解決しようとしている問題は何ですか?
+ 提案する解決策: どのように機能すればよいですか?
+ 検討した代替案: 他のアプローチはありますか?
+ 範囲: どの画面やフローが影響を受けますか?
+ 利用可能であればモックアップや参考画像。
+ タイトル、プライバシー、範囲
+ 報告をトリアージしやすく安全に共有できるようにします。
+ 良い Issue タイトルの例
+ イコライザー: プリセットタブを切り替えるとインジケーターがずれる
+ 検索: 空のクエリで履歴リストが表示されない
+ 機能: 「最近追加された」プレイリストの並び替えオプションを追加
+ 避けるべきこと
+ 「動かない」のような一般的な報告。
+ 1 つの Issue に複数の無関係な問題を含める。
+ プライベートデータが含まれた未編集のログやスクリーンショット。
+ プライバシーについて
+ ログ、スクリーンショット、動画を投稿する前に個人情報やプライベートな情報を削除してください。
+ ナイトリービルド
+ ナイトリーとリリースの違い、および破損した場合に含めるべき情報。
+ ナイトリービルドは最新のコミットから生成され、未完成の変更、一時的なバグ、またはリグレッションが含まれる場合があります。公式リリースよりも試験的です。
+ 利用可能な場合はリポジトリの GitHub Actions ワークフローアーティファクトからアクセスできます。
+ ナイトリーの問題を報告する
+ ナイトリービルドで問題を報告する場合は、公式リリースではなくナイトリービルドで発生したことを必ず記載してください。可能であればビルド日、ワークフロー実行名または番号、コミット SHA を含めてください。また同じ問題が最新の公式リリースでも発生するか確認してください。
+ Beta 0.5.0 アップグレード
+ クリーンインストール推奨
+ beta 0.5.0 からのアップデートの場合、このアップデートでは古いキャッシュ状態ではなく新しいライブラリデータが必要な場合があります。
+ メタデータやライブラリエントリがおかしい場合
+ 曲のメタデータが間違っている、アーティストやアルバムが一致しない、または重複しているように見えるエントリは通常クリーンインストールで解決します。
+ 今後表示しない
+ 了解
+
+
+ 問題が発生しました
+ 前回のセッション中にアプリがクラッシュしました。クラッシュレポートを共有して修正にご協力ください。
+ 日時: %1$s
+ エラー:
+ スタックトレース(プレビュー):
+ クラッシュログ
+ クラッシュログをクリップボードにコピーしました
+ PixelPlayer クラッシュレポート
+ クラッシュレポートを共有
+
+
+ DJ ミキサー
+
+
+ あなたの\nミックス
+ まだ表示するデータがありません
+ PixelPlayer が曲を見つけるかソースを同期するとミックスがここに表示されます。
+ 更新
+
+
+ デイリーミックス
+ 履歴に基づく
+ デイリーミックスをすべて確認
+ デイリーミックス
+
+
+ デイリーミックス
+
+ - %1$d 曲 • %2$s
+
+ 再生する
+ AI プレイリストジェネレーター
+
+
+ デイリーミックスの作られ方
+ デイリーミックスはお気に入りのよく再生される曲から作られます。好みのアーティストやジャンルのトラックも追加されるので新しい音楽を発見できます。
+ 今日何を聴きたいか AI に伝えましょう
+ コストを抑えるため少量のサンプルを使用します
+ 更新中…
+ デイリーミックスを更新
+
+
+ 完璧にキュレーション
+ デイリーミックス
+ あなたのソニックジャーニーの準備ができました
+ AI プレイリストジェネレーター
+ 雰囲気、ムード、アクティビティを説明して、ライブラリから AI に完璧なプレイリストをキュレーションさせましょう。
+ プレイリストのサイズ
+ 最小曲数
+ 最大曲数
+ 例: チルな夜の雰囲気、アップビートなワークアウトエネルギー…
+ タップして再試行
+ ソニックジャーニーが完成しました!
+ 再生準備完了
+ 生成中…
+ プレイリストを生成
+
+
+ 最近再生した曲
+
+
+ 最近再生した曲
+ 最新を再生
+ %1$s に最近の再生はありません
+ 範囲を変更するか、タイムラインを埋めるためにもっと曲を再生してください。
+ 最近再生した曲
+ 今日
+ 昨日
+
+
+ リスニング統計
+ 総再生回数
+ 1 日平均
+ トップトラック
+ %1$s • %2$d 回
+
+
+ リスニング統計
+ リスニング統計を更新
+ 今日
+ 今週
+ 今月
+ 今年
+ 全期間
+ リスニング
+ 再生
+ リスニングタイムライン
+ リスニング時間
+ 選択した範囲でのリスニングの合計。
+ 再生回数
+ セグメントごとに完了したセッション数。
+ 平均セッション
+ 各セグメントの平均リスニング時間。
+ 4 時間ごとに分割して日々のリズムを確認できます。
+ 日別バーで週ごとの習慣を比較しやすくします。
+ 週別バーで月のトレンドを確認できます。
+ 月別バーで年間の季節性を確認できます。
+ 年別バーで全履歴を要約します。
+ まだリスニングデータがありません
+ 再生を始めてリスニングタイムラインを構築しましょう
+ 日々のリズム
+ 週のリズム
+ 月のリズム
+ 年間一覧
+ 全期間の推移
+ 4 時間ごとのセグメントでグループ化
+ 曜日でグループ化
+ 月の週でグループ化
+ 月でグループ化
+ 年でグループ化
+ ピークセグメント
+ %1$d 回
+ —
+ トップカテゴリ
+ ジャンル、アーティスト、アルバム、曲ごとのリスニングを比較します。
+ ジャンル
+ アーティスト
+ アルバム
+ 曲
+ ジャンル別リスニング
+ アーティスト別リスニング
+ アルバム別リスニング
+ 曲別リスニング
+ %1$d 回 • %2$d アーティスト
+ %1$d 回 • %2$d トラック
+ まだカテゴリデータがありません
+ 再生を始めてリスニングのハイライトを確認しましょう
+ リスニング習慣
+ まだ習慣データがありません
+ あなたのことをより知ったらリスニング習慣を表示します。
+ 総セッション数
+ 平均セッション
+ 最長セッション
+ セッション/日
+ 最もアクティブな日
+ まだ再生履歴がありません
+ ピークタイムラインスロット
+ トップアーティスト
+ トップアーティストがいません
+ 聴き続けるとお気に入りのアーティストがここに表示されます。
+ \?
+ %1$d. %2$s
+ トップアルバム
+ トップアルバムがありません
+ よく聴くアルバムがここに表示されます。
+ %1$d. %2$s
+ トラック集中度
+ トップトラック全体でリスニング時間がどのように分散しているか。
+ まだ集中度データがありません
+ より多くのトラックを再生してリスニングの集中度を確認しましょう。
+ トップ 1
+ トップ 2-3
+ その他
+ %1$d%%
+ リスニング集中度
+ トップ 3 トラックがリスニング時間の %1$d%% を占めています。
+ 平均再生回数/トラック
+ ユニークトラック
+ トップ 3 シェア
+ この期間のトラック
+ 選択した期間で最も再生されたトラック。
+ トップトラックがありません
+ お気に入りを聴き続けるとここでハイライトされます。
+ トラックを折りたたむ
+ すべてのトラックを表示
+
+
+ %1$d 時間 %2$02d 分
+ %1$d 分
+ %1$d 時間 %2$02d 分
+ %1$d 時間
+ %1$d 分
+ %1$d 秒
+ %1$d 時間 %2$02d 分
+ %1$d 時間
+ %1$d 分
+ %1$d 秒
+ なし
+ たった今
+ 1 日前
+ %1$d 日前
+ 1 時間前
+ %1$d 時間前
+ 1 分前
+ %1$d 分前
+ %1$d 曲
+ %1$d 曲
+ 第 %1$d 週
+
diff --git a/app/src/main/res/values-ja/strings_library.xml b/app/src/main/res/values-ja/strings_library.xml
new file mode 100644
index 000000000..4c7c2d6ca
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_library.xml
@@ -0,0 +1,558 @@
+
+
+
+ ライブラリ
+ ライブラリタブ
+ 任意のタブへ直接ジャンプするか、順序を変更できます。
+ タブを並び替え
+
+
+ 曲
+ アルバム
+ アーティスト
+ プレイリスト
+ フォルダ
+ お気に入り
+
+
+ プレイリストを作成しました
+ 先に AI プロバイダーの API キーを設定してください
+ 先に Gemini API キーを設定してください
+ キューに追加しました
+ 次に再生
+
+
+ Watch への転送
+ 設定
+ 編集
+ タブを並び替え
+ メニューを展開
+
+
+ 選択できるアルバムは最大 %1$d 枚です
+ フォルダ
+ フォルダ
+
+
+ 並び替え
+ 表示
+ プレイリスト表示
+ グリッド
+ リスト
+ 内部ストレージ
+ SD カード
+ SD カードは現在利用できません。
+ クラウド
+ Telegram クラウドチャンネル
+ トピック表示
+ チャンネル
+ トピック
+ 両方
+ クラウド
+ クラウドのみ
+
+
+ AI でメタデータを生成中…
+
+
+ 曲の読み込みエラー
+ アルバムの読み込みエラー
+ アーティストの読み込みエラー
+ 再試行
+
+
+ ライブラリに曲が見つかりませんでした。
+ 端末に音楽がある場合は、設定からライブラリを再スキャンしてみてください。
+ 曲が見つかりません
+
+
+ 新規
+ 新しいプレイリストを作成
+ M3U プレイリストをインポート
+ 現在の曲を探す
+ すべての曲
+ クラウド
+ ローカル
+ 並び替えオプション
+
+
+ すべて
+ 選択解除
+ その他のオプション
+
+
+ 音楽ファイルをスキャン中…
+ ファイルを処理中…
+ %2$d 件中 %1$d 件
+ ライブラリを同期中…
+ 同期完了
+ 待機中…
+ ライブラリを同期中…
+ アルバムアートキャッシュをクリア中…
+ クラウドソースを同期中…
+ 歌詞をスキャン中…
+
+
+ 曲がまだありません
+ 音楽を端末に追加するか、クラウドソースを同期して再生を始めましょう。
+ ローカルの曲が見つかりません
+ 別のソースフィルターを試すか、端末のライブラリを再スキャンしてください。
+ クラウドの曲が見つかりません
+ Telegram や NetEase の曲を同期するか、ローカルソースに切り替えてください。
+ アルバムがありません
+ ライブラリにトラックがグループ化されるとアルバムが表示されます。
+ ローカルアルバムが見つかりません
+ ローカルアルバムを作成するにはローカルの曲が必要です。
+ クラウドアルバムが見つかりません
+ アルバムデータを持つクラウドの曲は同期後にここに表示されます。
+ アーティストがいません
+ いずれかのソースから曲がインデックスされるとアーティストが表示されます。
+ ローカルアーティストが見つかりません
+ ローカルの曲にアーティストのメタデータがありません。
+ クラウドアーティストが見つかりません
+ リモートの曲が同期されるとクラウドアーティストが表示されます。
+ お気に入りの曲がまだありません
+ 再生中にハートアイコンをタップして曲を保存しましょう。
+ お気に入りのローカル曲がありません
+ ソースフィルターを切り替えるか、端末の曲をお気に入りに追加してください。
+ お気に入りのクラウド曲がありません
+ Telegram や NetEase のトラックをお気に入りに追加するとここに表示されます。
+ フォルダが見つかりません
+ 音楽が入った内部ストレージのフォルダがここに表示されます。
+ プレイリストがまだありません
+ 最初のプレイリストを作成してライブラリを整理しましょう。
+
+
+ 曲のメタデータを編集
+ 再生
+ 曲を再生
+ すべて再生
+ すべて再生
+ お気に入りに追加
+ すべてお気に入りに追加
+ お気に入りから削除
+ すべてお気に入りから削除
+ 曲ファイルを共有するアプリを選択
+ 曲ファイルを共有
+ すべてを ZIP で共有
+ 曲を共有できませんでした: %1$s
+ キューに追加
+ キューに追加
+ 次に再生
+ キューで次に再生
+ プレイリストに追加
+ 削除
+ すべて削除
+ Watch を確認中
+ 転送中 %1$d%%
+ Watch に転送中
+ 転送中
+ Watch に送る
+ Watch が利用できません
+ 曲を Watch に送る
+ Watch が利用できません
+ サウンドとして設定
+ サウンドとして設定
+ この曲をシステムサウンドとして使う方法を選択
+ この曲を使う場所
+ PixelPlayer がこのサウンドをインストールする場所を選択してください。
+ 着信音
+ 電話の着信
+ 通知音
+ メッセージとアプリの通知
+ アラーム音
+ 時計のアラーム
+ サウンドの変更を確認
+ 「%1$s」を %2$s に設定しますか?
+ サウンドを設定
+ 「%1$s」を %2$s に設定しました
+ 着信音
+ 通知音
+ アラーム音
+ 「システム設定の変更」を有効にしてから PixelPlayer に戻ると自動で完了します。
+ 「システム設定の変更」が有効になっていません。
+ 「%1$s」を着信音に設定しました
+ 着信音にはローカルの曲のみ使用できます。
+ この音声ファイルを着信音用に準備できませんでした。
+ 着信音を設定できませんでした: %1$s
+ オプション
+ オプション
+ 情報
+ 情報
+ 再生時間
+ ジャンル
+ アルバム
+ アーティスト
+ 曲の情報
+ プロバイダー
+ ファイル
+ %1$d 曲
+ 選択中
+ %1$d プレイリスト
+ %1$d アルバム
+ 選択中
+ 上限: %1$d アルバム
+ キューへの追加と再生は選択順序に従います。
+ %1$d ジャンル
+ 選択中
+ 選択したジャンル内のすべての曲に対して一括操作を実行します。
+
+
+ デフォルト順
+ タイトル(A〜Z)
+ タイトル(Z〜A)
+ アーティスト
+ アーティスト(Z〜A)
+ アルバム
+ アルバム(Z〜A)
+ 追加日
+ 追加日(古い順)
+ 再生時間
+ 再生時間(短い順)
+ リリース年
+ リリース年(古い順)
+ 曲数が少ない順
+ 曲数が多い順
+ 名前(A〜Z)
+ 名前(Z〜A)
+ 曲数(多い順)
+ 曲数(少ない順)
+ 作成日
+ 作成日(古い順)
+ お気に入り追加日
+ お気に入り追加日(古い順)
+ サブフォルダが少ない順
+ サブフォルダが多い順
+
+
+ タイトル
+ アーティスト
+ アルバム
+ 追加日
+ 再生時間
+ リリース年
+ 曲数
+ 名前
+ 曲数
+ 作成日
+ お気に入り追加日
+ サブフォルダ数
+
+
+ ソース
+ 順序
+ 降順
+ 昇順
+ 元の順序
+ タップして昇順に切り替え
+ タップして降順に切り替え
+ この並び替えは元の順序を維持します
+ スイッチがオン
+
+
+ ライブラリタブを並び替え
+ 順序をリセット
+ タブの順序をデフォルトに戻しますか?
+ タブを並び替え中…
+ ドラッグハンドル
+
+
+ アーティストを選択
+ 1 アーティスト
+ %1$d アーティスト
+ メインアーティスト
+ アーティストページ
+
+
+ 転送をキャンセル
+ %1$s / %2$s
+ スマートフォンから Watch への音楽転送の進捗をリアルタイムで表示します
+ Watch への転送
+ Watch に送信中
+ キャンセル済み
+ 転送をキャンセルしました
+ 転送が完了しました
+ 完了
+ 失敗
+ 転送に失敗しました
+ 複数の転送が進行中
+ %1$s • %2$s
+ 準備中
+ Watch への転送を準備中
+ 転送を準備中…
+ Watch に %1$d 曲を送信中
+ Watch に送信中
+ 転送を開始中…
+ 開始中
+ 転送中
+ %1$d 件の転送
+
+
+ 曲を編集
+ 情報を表示
+ 曲のメタデータを編集中
+ 曲のメタデータを編集すると、ライブラリでの表示や整理に影響することがあります。変更は永続的で、元に戻せない場合があります。
+ 了解
+ 情報
+ カバーアート
+ 正方形の画像を選択して調整し、アプリ全体でカバーアートが美しく表示されるようにしましょう。
+ カバーアートを変更
+ カバーアートを削除
+ タイトル
+ アーティスト
+ アルバム
+ アルバムアーティスト
+ ジャンル
+ 作曲者
+ トラック番号
+ ディスク番号
+ ReplayGain トラック(dB)
+ ReplayGain アルバム(dB)
+ -6.50
+ -8.20
+ 新しいカバーアートのプレビュー
+ 現在の曲のカバーアート
+ カバーアートを調整
+ ピンチとドラッグで最適なフレーミングを見つけてください。
+ カバーアートを適用
+ 選択した画像を読み込めませんでした
+ lrclib.net で歌詞を検索
+
+
+ %d 曲を編集
+ 変更したフィールドのみ更新されます。空白のフィールドは既存の値が保持されます。
+ (複数の値)
+ (任意 — スキップする場合は空白のまま)
+ %d 曲を更新しました
+ %2$d 曲中 %1$d 曲を更新しました。一部のファイルは編集できませんでした。
+ 曲の更新に失敗しました
+ カバーアートの一括変更
+ 選択した %d 曲すべてのカバーアートが置き換えられます
+ すべてにカバーアートを設定
+ すべてのカバーアートを削除
+ (複数の異なるカバー)
+
+
+ プレイリストを閉じました
+
+
+ プレイリストを作成
+ 作成方法を選択してください。
+ 手動
+ アートワーク・アイコン・形状をデザインし、曲を自分で選びます。
+ AI で作成
+ 高度なコントロールでキュレーションされたプレイリストを生成します。
+ 設定で Gemini API キーを設定する必要があります。
+ API キーを設定
+
+
+ AI プレイリストラボ
+ リセット
+ 生成中…
+ 生成
+ 意図
+ プレイリスト名(任意)
+ このプレイリストの雰囲気は?
+ 例:夕暮れのドライブにウォームなシンセ
+ 方向性
+ ムード
+ アクティビティ
+ 年代
+ キュレーション
+ エネルギー
+ 曲の強度とテンポを調整します。1 = 穏やか/スロー、5 = ハイエネルギー/ファスト。
+ ディスカバリー
+ 選曲の馴染み度を調整します。1 = 最もよく聴くお気に入り、5 = あまり聴いていないレアな曲。
+ 最小曲数
+ 最大曲数
+ フィルター
+ 優先するジャンル(任意)
+ 例:シンセウェーブ、インディーポップ
+ 避けるジャンル(任意)
+ 例:メタル、ハードトラップ
+ 優先言語(任意)
+ 例:日本語、英語、インストゥルメンタル
+ お気に入りを優先
+ 不適切な歌詞を除外
+ プロンプトのプレビュー
+ 好みを追加すると最終プロンプトがここに表示されます。
+ 精密なキュレーション
+ ムード・アクティビティ・制約・深さを定義します。
+ AI はローカルライブラリの曲のみを使用します。
+ AI への指示を少なくとも 1 つ追加してください。
+ 有効な曲数の範囲を設定してください。
+ %1$d/5
+ カスタム…
+ カスタム値を入力
+ カスタム値を入力してください
+
+
+ すべての年代
+ コアリクエスト: %1$s。
+ ムード目標: %1$s。
+ アクティビティ: %1$s。
+ 年代: %1$s。
+ 優先ジャンル: %1$s。
+ 避けるジャンル: %1$s。
+ 優先言語: %1$s。
+ エネルギーレベル目標: %1$d/5。
+ ディスカバリー目標: %1$d/5(1 = 馴染みあり、5 = レアな掘り出し物)。
+ 可能な限りお気に入りに近い曲を優先する。
+ 代替曲がある場合は不適切な歌詞を避ける。
+ スムーズなトランジションを維持し、同じアーティストが連続しないようにする。
+
+ - チル
+ - エネルギッシュ
+ - ハッピー
+ - ダーク
+ - ロマンティック
+ - メランコリック
+
+
+ - ワークアウト
+ - 集中
+ - ロードトリップ
+ - パーティー
+ - 勉強
+ - 深夜
+
+
+ - @string/playlist_creation_ai_era_any
+ - 70年代
+ - 80年代
+ - 90年代
+ - 2000年代
+ - 2010年代
+ - 2020年代
+
+
+
+ プレイリストがまだ作成されていません。
+ 「新しいプレイリスト」ボタンをタップして始めましょう。
+ 新しいプレイリスト
+ プレイリスト名
+ マイプレイリスト
+
+
+ %1$d 曲を追加先…
+ プレイリストを選択
+ プレイリストを検索…
+ プレイリストに曲を追加しました
+ プレイリストを作成して曲を追加しました
+ 内部ストレージ
+
+
+ 曲を追加
+ 選択した曲を追加
+ 追加
+ 曲を検索またはフィルター…
+ お気に入り
+ 曲の読み込みに失敗しました
+ さらに読み込む
+
+
+ プレイリストを結合
+ 結合後のプレイリスト名を入力してください:
+ 結合プレイリスト
+ 選択した %1$d 件のプレイリストを 1 つに結合します。
+
+
+ 再生できる有効な曲が見つかりませんでした
+ 現在のリストに曲が見つかりません
+ 曲を見つけられませんでした
+ ライブラリに曲が見つかりません
+ %1$s の再生が終了しました(トラック終了)。
+ トラック
+ シャッフルする曲がありません。
+ 選択したアルバム
+ 選択したアルバムに再生可能な曲が見つかりませんでした
+ 選択したジャンルに再生可能な曲が見つかりませんでした
+ 最初の %1$d アルバムのみキューに追加しました
+ %1$d アルバムをキューに追加しました(%2$d 曲)
+ 選択したアルバムをキューに追加できませんでした
+ すべての曲がすでにお気に入りにあります
+ お気に入りに曲がありませんでした
+ ZIP ファイルを作成中…
+ 共有に失敗しました: %1$s
+
+ - %d 曲をキューに追加しました
+
+
+ - %d 曲が次に再生されます
+
+
+ - %d 曲をお気に入りに追加しました
+
+
+ - %d 曲をお気に入りから削除しました
+
+
+
+ 共有するプレイリストがありません
+ プレイリストを共有
+ 共有に失敗しました: %1$s
+ エクスポートするプレイリストがありません
+ エクスポートに失敗しました: %1$s
+ Music/PixelPlayer Exports
+ 設定で Gemini API キーを設定してください。
+ プレイリストを復元しました
+
+ - %d 件のプレイリストを共有中
+
+
+ - %2$s に %1$d 件のプレイリストをエクスポートしました
+
+
+
+ 無効なアルバム ID
+ アルバム ID が見つかりません
+ アルバムデータの読み込みエラー: %s
+ アルバムが見つかりません
+
+
+ 無効なアーティスト ID
+ アーティスト ID が見つかりません
+ アーティストデータの読み込みエラー: %s
+ アーティストが見つかりませんでした
+
+
+ 再生中の曲は削除できません
+ %1$d 件のファイルを削除しました(%2$d 件スキップ — 再生中)
+ %2$d 件中 %1$d 件のファイルを削除しました
+ ファイルの削除に失敗しました
+ ファイルを削除しました
+ ファイルを削除できないか、見つかりません
+ 削除をキャンセルしました
+ 曲を削除しますか?
+ %2$s の「%1$s」\n\nこの曲は端末から完全に削除され、元に戻せません。
+ これらの曲は端末から完全に削除され、元に戻せません。
+
+ - %d 件のファイルを削除しました
+
+
+ - %d 曲を削除しますか?
+
+
+
+ メタデータを更新しました
+ %1$d 曲を更新中…
+ %1$d 曲を正常に更新しました!
+ %1$d 曲を更新しました。失敗: %2$d 曲
+ 歌詞を保存しました
+ 歌詞の保存に失敗しました
+ 保存できる歌詞がありません
+ 権限が拒否されました — ファイルを編集できません
+ 権限が拒否されました — 歌詞を保存できません
+ 権限が拒否されました — このファイルを編集できません
+
+
+ 設定で選択した AI プロバイダーの有効な API キーを設定してください。
+ AI エラー: %s
+ 選択した AI プロバイダーはアカウントのクレジットまたはクォータが不足しているためリクエストを拒否しました。
+ 選択した AI モデルは利用できなくなりました。PixelPlayer がサポート対象のモデルへ自動的に切り替えを試みました。
+ AI がプロンプトに合う曲を見つけられませんでした。
+ デイリーミックスのアイデアを書いてください
+ AI でデイリーミックスを更新しました
+ 更新できませんでした: %s
+ AI がこのミックスに合う曲を見つけられませんでした
+
diff --git a/app/src/main/res/values-ja/strings_player.xml b/app/src/main/res/values-ja/strings_player.xml
new file mode 100644
index 000000000..eae601ef3
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_player.xml
@@ -0,0 +1,195 @@
+
+
+
+ プレイヤーを閉じる
+ 再生中
+ クラウドストリーム
+ キャスト
+ Bluetooth
+ 本体再生
+ 接続中…
+ キューを開く
+
+
+ 接続の準備
+ キャスト・Bluetooth オーディオ・スピーカーを同期するために、PixelPlayer が近くのデバイスと現在の Wi‑Fi を確認できるよう許可してください。
+ 近くのデバイス
+ 接続済み Bluetooth オーディオ機器の読み取りと制御に必要です。
+ Wi‑Fi 用の位置情報
+ Android では、互換性のあるキャストデバイスを検出するために Wi‑Fi ネットワーク(SSID)の共有に位置情報が必要です。
+ アクセスを許可
+ これらの権限はデバイスの相互接続(キャスト・近くのスピーカーの制御・オーディオ同期)にのみ使用します。
+ デバイスを接続
+ 近くをスキャン中
+ キャストセッション
+ 接続中
+ 接続済み
+ このスマートフォン
+ Bluetooth オーディオ
+ 本体再生
+ 再生中
+ 一時停止中
+ デバイスの音量
+ スマートフォンの音量
+ %1$d/%2$d
+ バッテリー残量
+ 音量レベル
+ 切断
+ 接続性
+ Wi-Fi または Bluetooth をオンにしてください
+ 接続を更新
+ Wi-Fi
+ オフ
+ オン
+ 接続済み
+ Bluetooth
+ オフ
+ オン
+ 接続済み
+ 近くのデバイス
+ デバイスを更新
+ 接続済み
+ 接続中
+ 接続可能
+ 利用可能
+ 接続中...
+ デバイスを検索中…
+ テレビやスピーカーの電源が入っており、同じ Wi‑Fi ネットワークに接続されていることを確認してください。
+ コントロール
+ デバイス
+
+
+ キャストメディアサーバー
+ デバイスにキャスト中
+ キャストデバイスにメディアを配信中
+ %1$s: %2$s
+ このオーディオフォーマットはキャスト中にシークするとセッションがクラッシュする可能性があるため、一時的に利用できません。
+
+
+ スリープタイマー
+ タイマー
+ %1$d 分
+ %1$d 分後にタイマーをセットしました。
+ 1 回
+
+ - %d 回
+
+ 再生回数: %1$s
+ 現在のトラックの終わり
+ トラックの終わりで再生を停止します。
+ スイッチをオン
+ カスタム時間
+ タイマーをキャンセル
+ トラックの終わり
+ タイマーをキャンセルしました。
+ 再生中の曲がないため、トラック終了タイマーを有効にできません。
+ 曲が %1$s から %2$s に変わったため、トラック終了タイマーを無効にしました。
+ 前のトラック
+ 現在のトラック
+ カスタム時間を設定
+
+
+ 次の曲
+ キューはまだ空です。
+
+ - %d 曲待機中
+
+ キュー
+ キューは空です。
+ 曲を並び替え
+ シャッフルを切り替え
+ リピートを切り替え
+ スリープタイマー
+ その他の操作
+ 現在の曲を探す
+ キューをクリア
+ キューをクリア
+ 現在再生中の曲以外をすべてキューから削除しますか?
+ プレイリストとして保存
+ %1$s のキュー
+ 現在のキュー
+ 曲を削除
+ 削除しました
+ プレイリストとして保存
+ すべて選択解除
+ プレイリスト名
+ 含める曲を検索…
+ 「%1$s」に一致する曲はありません
+
+ - %d 曲を選択中
+
+ %1$s として保存
+ プレイリスト名を入力
+ プレイリストから削除
+ %1$s のその他のオプション
+
+
+ 歌詞
+ 歌詞を読み込み中…
+ 同期あり
+ テキストのみ
+ 歌詞オプション
+ −.5
+ −.1
+ +.1
+ +.5
+ 0s
+ %1$+.1f 秒
+
+
+ 歌詞の検索に失敗しました
+ リモートからの歌詞取得に失敗しました
+ 接続がタイムアウトしました。インターネット接続を確認してください。
+ ネットワークエラー。インターネット接続を確認してください。
+ サーバーエラー(コード %d)。しばらくしてから再試行してください。
+
+
+ 歌詞はすでに利用可能です。オンライン取得をスキップしました。
+ 埋め込み歌詞が見つかりました。オンライン取得をスキップしました。
+ ローカル(.lrc)歌詞が見つかりました。オンライン取得をスキップしました。
+
+
+ 歌詞を保存
+ AI で翻訳
+ この歌詞にはすでに翻訳があります
+ この歌詞はすでにこの言語です
+ API が設定されていません
+ 歌詞の翻訳が完了しました!
+ 歌詞を翻訳中...
+ インポートした歌詞をリセット
+ 歌詞をリセットしますか?
+ この曲の歌詞をリセットしてもよろしいですか?
+ 表示
+ 配置
+ 左揃え
+ 中央揃え
+ 右揃え
+ コントロール
+ 同期を調整
+ 同期コントロールを非表示
+ ローマ字表記を表示
+ 翻訳を表示
+ 没入モードを一時解除
+ 画面をオンに保つ
+
+
+ 歌詞を保存
+ 保存するバージョンを選択してください:
+ 同期あり(タイムスタンプ付き)
+ テキストのみ
+
+
+ 歌詞をオンラインで検索しますか?
+ 歌詞の候補を表示
+ 最初の候補を自動適用せず、常に選択画面を開く
+ 歌詞を検索中…
+ 歌詞が見つかりませんでした
+ 歌詞を自動で見つけられませんでした。タイトルやアーティスト名を編集して手動で検索できます。
+ 曲名
+ アーティスト(任意)
+ %d 件見つかりました
+ 同期あり
+ %1$s • %2$s
+ 歌詞提供元:
+ https://lrclib.net/
+
diff --git a/app/src/main/res/values-ja/strings_screens.xml b/app/src/main/res/values-ja/strings_screens.xml
new file mode 100644
index 000000000..be88a28c0
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_screens.xml
@@ -0,0 +1,244 @@
+
+
+
+ エラー: ジャンル ID がありません
+
+
+ 始めましょう!
+ ステップ %1$d / %2$d
+ 先に必要な権限を許可してください。
+ 必要な権限をすべて許可してください。
+ ようこそ
+ β
+ ベータ
+ セットアップを完了しましょう。
+ メディアの権限
+ 音楽ライブラリを構築するために、PixelPlayer がオーディオファイルへアクセスする必要があります。
+ 権限が許可されました
+ メディア権限を許可
+ 通知
+ ロック画面や通知シェードから音楽を操作するために通知を有効にします。
+ 通知を有効化
+ バックアップはありますか?
+ PixelPlayer のバックアップがある場合は今すぐ復元することでこのデバイスのセットアップの大部分をスキップできます。
+ バックアップをインポート
+ バックアップを確認中
+ バックアップパッケージを確認中…
+ バックアップを復元中
+ スキップ / あとで
+ バックアップを復元
+ セットアップを完了する前にインポートする内容を確認してください。
+ %2$d モジュール中 %1$d を選択中
+ %1$s に作成
+ %1$s からのバックアップ
+ バージョン不明
+ 選択を復元
+ 復元中
+ 除外フォルダ
+ デフォルトではすべてのフォルダがスキャンされます。ライブラリ構築時に無視する場所を選択してください。
+ 無視するフォルダを選択
+ 先にストレージの権限を許可してください
+ アプリのテーマ
+ ライブラリの探索を始める前に好みの外観を選んでください。
+ ダーク
+ PixelPlayer のデフォルトの Material 3 ダーク外観。
+ ライト
+ アプリ全体のより明るい Material 3 外観。
+ システムに合わせる
+ スマートフォンの現在の外観設定に合わせます。
+ おすすめ
+ 後から 設定 > 外観 > アプリのテーマ で変更できます。
+ ライブラリレイアウト
+ ライブラリのナビゲーション方法を選択してください。
+ 曲
+ コンパクトモード
+ 最小化されたピルナビゲーションを使用
+ 標準のタブ行を使用
+ 曲
+ アルバム
+ アーティスト
+ 後から 設定 > 外観 > ライブラリナビゲーション で変更できます。
+ アプリナビゲーション
+ ボトムナビゲーションバーのスタイルを選択してください。
+ デフォルトスタイル
+ 角が丸いフローティングピル
+ 標準のフル幅バー
+ コーナー半径をカスタマイズ
+ 後から 設定 > 外観 > ナビバースタイル で変更できます。
+ アラームとリマインダー
+ 任意ですが、スリープタイマーを使用して PixelPlayer を正確な時刻に停止させたい場合はおすすめです。
+ 権限を許可
+ バッテリー最適化
+ 一部の Android 端末はバックグラウンドアプリを積極的に終了させます。予期しない再生の中断を防ぐために PixelPlayer のバッテリー最適化を無効にしてください。
+ 最適化を無効化
+ 準備完了!
+ 音楽を楽しむ準備ができました。
+
+
+ 検索…
+ 検索
+ 検索をクリア
+ 最近の検索
+ すべてクリア
+ 履歴
+ 検索履歴アイテムを削除
+ 結果なし
+ 「%1$s」の検索結果はありません
+ 見つかりませんでした
+ 別の検索語またはフィルターを試してください。
+ 結果が見つかりませんでした。
+ ジャンルで探す
+ 利用可能なジャンルがありません。
+
+
+ %1$s を再生
+ %1$s を折りたたむ
+ %1$s を展開
+ アーティスト画像を編集
+ 写真を変更
+ デフォルトに戻す
+ アーティストをシャッフル再生
+
+
+ ディスク %d
+ %1$s のカバー
+ %1$s · %2$s
+
+
+ プレイリストが見つかりません。
+ このプレイリストは空です。
+ 「曲を追加」をタップして始めましょう。
+ このフォルダに曲はありません。
+ 曲を並び替え
+ その他のオプション
+ プレイリストのオプション
+ プレイリストを編集
+ プレイリストを削除
+ プレイリストを削除しますか?
+ このプレイリストを本当に削除しますか?
+ デフォルトトランジションを設定
+ プレイリストをエクスポート
+ %1$s • %2$s
+ 再生する
+ 追加
+ 曲を追加
+ 削除
+ 曲を削除
+ 並び替え
+ 曲を並び替え
+
+
+ グローバルトランジション
+ プレイリストルール
+ 上書きされない限り、すべての再生ソースにこの設定が適用されます。
+ この特定のプレイリストのデフォルト動作を設定します。
+ アクティブ状態
+ グローバルデフォルト
+ プレイリストデフォルト
+ グローバルに従う
+ カスタム上書き
+ カスタム上書き
+ 有効にするとこのプレイリストに特定のルールを設定できます。
+ グローバルデフォルトを使用
+ 変更を保存しました
+ トランジションスタイル
+ トラックのブレンド方法
+ なし
+ クロスフェード
+ トランジションの長さ
+ %1$d 秒のオーバーラップ
+ トランジションをリセット
+ 現在の曲
+ 次の曲
+ トラックは %1$d 秒間オーバーラップします
+ 音量カーブ
+ オーディオのスロープを微調整
+ フェードアウト
+ フェードイン
+
+
+ 新しいスマートプレイリスト
+ 新しいプレイリスト
+ 曲を追加
+ 戻るまたはキャンセル
+ 次へ
+ 作成
+ プレイリストを編集
+ 自動生成コラージュ
+ 写真を追加
+ 画像を選択
+ 変更
+ 削除
+ プレイリスト名
+ マイ素敵なミックス
+ カバーを編集
+ カバーアートを調整
+ ピンチとドラッグで最適なフレーミングを見つけてください
+ 手動
+ スマート
+ AI で生成
+ スマートルール
+ デフォルト
+ 画像
+ アイコン
+ 背景色
+ アイコンシンボル
+ 形状スタイル
+ 形状パラメーター
+ コーナー半径
+ 滑らかさ
+ 辺の数
+ カーブ
+ 回転
+ スケール
+ よく再生する曲
+ 最も再生されたトラック。
+ 最近再生した曲
+ 最近聴いた曲。
+ 忘れられたお気に入り
+ しばらく再生していないお気に入りのトラック。
+ 新着の宝石
+ 再生回数が少ない最近追加されたトラック。
+
+
+ ジャンルに曲を素早く追加
+ 並び替えと再生
+ シャッフル
+ 並び替え基準
+ アーティスト
+ アルバム
+ タイトル
+ 一般アーティスト
+ %1$s シャッフル
+
+
+ 曲を選択
+ ジャンルを選択
+ 曲を検索
+ 新しいジャンル
+ カスタムを追加
+ カスタムジャンルを追加
+ ジャンル名
+ アイコンを選択
+ ジャンル: %1$s
+ ジャンルを選択
+ 素早く追加
+
+
+ DJ スペース
+ 読み込み中…
+ デッキ %1$d
+ 曲を読み込む
+ 曲が読み込まれていません
+ …
+ ステム分離はまだ利用できません。
+ 音量
+ 速度
+ クロスフェーダー
+ デッキ 1
+ デッキ 2
+ 曲を選択
+ 再生/一時停止
+ 曲のカバー
+ x%1$.2f
+
diff --git a/app/src/main/res/values-ja/strings_settings.xml b/app/src/main/res/values-ja/strings_settings.xml
new file mode 100644
index 000000000..294ef388c
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_settings.xml
@@ -0,0 +1,652 @@
+
+
+
+ 音楽管理
+ フォルダ管理、ライブラリ更新、解析オプション
+ 外観
+ テーマ、レイアウト、ビジュアルスタイル
+ 再生
+ オーディオ動作、クロスフェード、バックグラウンド再生
+ 動作
+ ジェスチャー、触覚フィードバック、ナビゲーション動作
+ AI 連携(β)
+ AI プロバイダー、API キー、モデル設定
+ バックアップ & 復元
+ 個人データのエクスポートと復元
+ 開発者オプション
+ 試験的機能とデバッグ
+ イコライザー
+ 音域とプリセットの調整
+ デバイス情報
+ オーディオ仕様、コーデック、デコーダー情報
+ アカウント
+ Telegram、Google Drive、NetEase などのサービスを管理
+ このアプリについて
+ アプリ情報、バージョン、クレジット
+
+
+ オン
+ オフ
+ 有効
+ 無効
+ 開く
+ すべて選択
+ 選択を解除
+ 通知を閉じる
+
+
+ ライブラリ構造
+ 除外ディレクトリ
+ ここに追加したフォルダはライブラリスキャン時にスキップされます。
+ アーティスト
+ 複数アーティストの解析と整理オプション。
+ フィルタリング
+ 最低曲の長さ
+ アルバムの最低トラック数
+ アルバムアートキャッシュ上限
+ 同期とスキャン
+ ライブラリを更新
+ 新しいファイルや変更されたファイルをライブラリ全体からスキャンします。
+ フルリスキャン
+ フルリスキャン実行中
+ フルリスキャンを開始しました…
+ ライブラリ同期が完了しました
+ データベースを再構築
+ データベースを再構築しますか?
+ 音楽ライブラリを最初から完全に再構築します。インポートした歌詞、お気に入り、カスタムメタデータはすべて失われます。この操作は元に戻せません。
+ 再構築
+ データベースを再構築中
+ データベースを再構築中…
+ .lrc ファイルを自動スキャン
+ ライブラリ同期中に、同じフォルダ内の .lrc ファイルを自動でスキャンして割り当てます。
+ 歌詞管理
+ 歌詞ソースの優先順位
+ 歌詞を取得する際に最初に試みるソースを選択します。
+ 埋め込みを優先
+ オンラインを優先
+ ローカル(.lrc)を優先
+ インポートした歌詞をリセット
+ データベースからインポートした歌詞をすべて削除します。
+ インポートした歌詞をリセットしますか?
+ この操作は元に戻せません。
+
+
+ 更新
+ デフォルトではすべて許可されています。フォルダをタップするとスキャンから除外されます。
+ サブフォルダがありません
+ 上へ移動
+ ルートへ移動
+
+
+ リスキャンが必要です
+ アーティスト設定が変更されました。ライブラリをリスキャンして適用してください。
+ リスキャン
+ スキャン中…
+ 複数アーティストの解析
+ 文字区切り
+ 現在: %1$s
+ 単語区切り
+ なし
+ 現在: %1$s
+ 設定
+ タイトルからアーティストを抽出
+ 曲タイトルの feat., ft., with を検出
+ ライブラリ整理
+ アルバムアーティストでグループ化
+ コラボアルバムをメインアーティストの下に表示
+ 複数アーティスト解析について
+
+ PixelPlayer は文字区切り(/、;、&)と単語区切り(feat.、ft.、vs.、x)を使ってアーティストタグを分割します。単語区切りは大文字小文字を区別しません。
+ 「タイトルからアーティストを抽出」は曲タイトルの (feat. アーティスト名) のようなパターンを検出します。
+ バックスラッシュ(\)で文字区切りをエスケープできます。
+
+ 例
+ →
+ ♪
+ \"Artist1/Artist2\"
+ Artist1, Artist2
+ \"Drake feat. Rihanna\"
+ Drake, Rihanna
+ \"Marshmello x Bastille\"
+ Marshmello, Bastille
+ \"Song (ft. B)\" by A
+ A, B
+ \"AC\\DC\"
+ AC/DC(エスケープ済み)
+
+
+ 区切り文字
+ 現在の区切り文字
+ 区切り文字をタップして削除します。少なくとも 1 つ必要です。
+ 新しい区切り文字を追加
+ 例: / または ;
+ 区切り文字を追加
+ デフォルトの区切り文字
+ 区切り文字をリセットしますか?
+ カスタム区切り文字をすべてクリアしてデフォルトに戻します。この操作は元に戻せません。
+ 区切り文字をデフォルトにリセットしました
+ 少なくとも 1 つの区切り文字が必要です
+ 区切り文字を追加しました
+ すでに存在するか無効な区切り文字です
+ スペース
+
+
+ 単語区切り
+ 現在の単語区切り
+ スペースで囲まれているときにアーティスト名を分割するキーワードです。大文字小文字を区別しません。タップして削除。
+ 単語区切りが設定されていません
+ 新しい単語区切りを追加
+ 例: feat. または ft.
+ 単語区切りを追加
+ 単語区切りの仕組み
+ 単語区切りはスペースで囲まれている場合に大文字小文字を区別せずマッチします。\n\n1文字の区切り(例: \"x\")は誤マッチを防ぐために両側にスペースが必要です。\n\n例:\n \"Drake feat. Rihanna\" -> Drake, Rihanna\n \"Marshmello x Bastille\" -> Marshmello, Bastille\n \"A vs. B\" -> A, B
+ 単語区切りをリセットしますか?
+ カスタム単語区切りをすべてクリアしてデフォルトキーワードに戻します。この操作は元に戻せません。
+ 単語区切りを追加しました
+ すでに存在するか無効です
+ 単語区切りをデフォルトにリセットしました
+
+
+ 同期を準備中
+ MediaStore を読み込み中
+ トラックを処理中
+ データベースに保存中
+ 歌詞ファイルをスキャン中
+ アルバムアートキャッシュをクリア中
+ クラウドソースを同期中
+ 同期を完了中
+ %1$s • %2$d%% (%3$d/%4$d)
+ %1$s…
+
+
+ グローバルテーマ
+ アプリの言語
+ アプリ全体で使用する言語を選択します。
+ システムのデフォルト
+ English
+ Español
+ Deutsch
+ Français
+ Русский
+ 简体中文
+ Bahasa Indonesia
+ Italiano
+ 한국어
+ Norsk (Bokmål)
+ Türkçe
+ 日本語
+ アプリのテーマ
+ ライト、ダーク、またはシステムに合わせるを選択します。
+ ライトテーマ
+ ダークテーマ
+ システムに合わせる
+ スムーズコーナーを使用
+ 複雑な形状のコーナーを使用して見た目を向上させますが、ローエンド端末ではパフォーマンスに影響する場合があります。
+ ブラー効果を無効化
+ アプリ全体のブラー効果をオフにしてバッテリーとリソースを節約します。
+ スクロールバーを表示
+ 音楽リストにスクロールバーを表示してすばやくスクロールできます。
+ 再生中
+ プレイヤーテーマ
+ フローティングプレイヤーの外観を選択します。
+ アルバムアート
+ システムダイナミック
+ プレイヤーのファイル情報を表示
+ プレイヤーの進行バーにコーデック、ビットレート、サンプルレートを表示します。
+ アルバムアートパレットスタイル
+ 現在: %1$s。ライブプレビューを開いてスタイルを選択してください。
+ カルーセルスタイル
+ アルバムカルーセルの外観を選択します。
+ のぞき込みなし
+ のぞき込み 1 枚
+ ホームコラージュ
+ コラージュパターン
+ 「あなたのミックス」コラージュの形状を選択します。
+ パターンを自動ローテーション
+ ホームを訪れるたびにコラージュパターンを切り替えます。
+ ナビゲーションバー
+ ナビバースタイル
+ ナビゲーションバーの外観を選択します。
+ デフォルト
+ フル幅
+ コンパクトモード
+ アイコンのみ表示してナビバーの高さを縮小します。
+ ナビバーのコーナー半径
+ ナビゲーションバーのコーナー半径を調整します。
+ 歌詞画面
+ 没入型歌詞
+ コントロールを自動非表示にしてテキストを拡大します。
+ 自動非表示の遅延
+ コントロールが非表示になるまでの時間。
+ 3 秒
+ 4 秒
+ 5 秒
+ 6 秒
+ アプリナビゲーション
+ デフォルトタブ
+ 起動時のデフォルトタブを選択します。
+ ホーム
+ 検索
+ ライブラリ
+ ライブラリナビゲーション
+ ライブラリタブ間の移動方法を選択します。
+ タブ行(デフォルト)
+ コンパクトピル & グリッド
+
+
+ カラー
+ パレットスタイル
+ プレイヤー UI のアルバムカラーを選択します。
+ トーナルスポット
+ バランスが取れた落ち着いた雰囲気。
+ ビビッド
+ 高彩度のアクセント。
+ エクスプレッシブ
+ 大胆な色相シフトとコントラスト。
+ フルーツサラダ
+ 楽しい回転アクセント。
+ カラーの精度
+ 0 は現在の調整を維持します。高い値ほどアルバムアートの主要色に近くなります。
+ 現在
+ より正確
+ 0 • 現在
+ %1$d • 穏やか
+ %1$d • バランス
+ %1$d • 正確
+
+
+ コーナー半径を調整
+ ナビバーの形状のコーナーをデバイスの物理コーナーに合わせてシームレスな外観にします。
+ コーナー半径
+ %1$d dp
+
+
+ バックグラウンド再生
+ 閉じても再生を続ける
+ オフにすると、アプリを履歴から削除したときに再生が停止します。
+ バッテリー最適化
+ バッテリー最適化を無効にして再生の中断を防ぎます。
+ バッテリー最適化はすでに無効になっています
+ バッテリー設定を開けませんでした
+ 音量ノーマライゼーション(ReplayGain)
+ ReplayGain を有効化
+ オーディオファイルの ReplayGain メタデータを使って音量レベルを正規化します。
+ ゲインモード
+ トラック: 曲ごとに正規化。アルバム: アルバム単位で正規化。
+ トラック
+ アルバム
+ キャスト
+ キャスト接続/切断時に自動再生
+ キャスト接続を切り替えた直後に自動で再生を開始します。
+ ヘッドフォン
+ ヘッドフォン再接続時に再開
+ ヘッドフォンを外したために一時停止した場合、再接続すると自動で再開します。
+ キューとトランジション
+ クロスフェード
+ 曲間のスムーズなトランジションを有効にします。
+ クロスフェードの長さ
+ Hi-Fi モード
+ 32 ビット float オーディオ出力。端末で再生がカクつく場合は無効にしてください。
+ この端末ではサポートされていません(PCM_FLOAT AudioTrack 非対応)。
+ シャッフルを保持
+ アプリを閉じた後もシャッフル設定を記憶します。
+ キュー履歴を表示
+ キューに以前再生した曲を表示します。
+
+
+ フォルダ
+ 戻るジェスチャーでフォルダを操作
+ フォルダタブで、システムの戻る操作がライブラリを離れる前にフォルダ階層をさかのぼります。
+ プレイヤーのジェスチャー
+ 背景タップでプレイヤーを閉じる
+ ぼかした背景をタップするとプレイヤーシートが閉じます。
+ 触覚フィードバック
+ 触覚フィードバック
+ アプリ全体でバイブレーションフィードバックを有効にします。
+
+
+ AI プロバイダー
+ プロバイダー
+ AI プロバイダーを選択してください
+ セーフトークンモード
+ ON — 高速 & 低コスト。AI に最小限のデータ(約 1K トークン)を送信します。
+ OFF — 深いコンテキスト。より豊かな結果のためにリスニングプロフィール全体(約 8K トークン)を送信します。
+ 認証情報
+ %1$s API キー
+ %1$s から取得
+ Google AI Studio (aistudio.google.com)
+ DeepSeek Platform (api.deepseek.com)
+ Groq Console (console.groq.com)
+ Mistral AI Platform (console.mistral.ai)
+ NVIDIA Build (build.nvidia.com)
+ Moonshot AI Platform (platform.moonshot.cn)
+ Zhipu AI Open Platform (bigmodel.cn)
+ OpenAI Platform (platform.openai.com)
+ モデル選択
+ 利用可能なモデルを読み込み中…
+ モデルの読み込みに失敗しました
+ AI モデル
+ モデルを選択してください。
+ API キーを入力
+ プロンプト動作
+ システムプロンプト
+ AI の動作をカスタマイズします。
+ プリセットプロンプト
+ システムプロンプトを入力…
+ プロフェッショナルキュレーター
+ あなたは「Vibe-Engine」という世界トップクラスの音楽キュレーターで、ソニックフローの達人です。シームレスで高品質なリスニング体験を提供することが目標です。和声の相性、論理的な BPM トランジション、馴染みのお気に入りと洗練された発見のバランスを優先してください。
+ クリエイティブマーベリック
+ あなたは「予期しない統一感」を専門とする前衛的な音楽探求者です。非自明なソニックの共通点を見つけることで従来のジャンルの壁を打ち破ることが使命です。レアなディープカット、実験的なテクスチャー、芸術的な新しさを優先しながら、驚きつつも否定できないトランジションロジックを維持してください。
+ 厳格な司書
+ あなたは精密な音楽データベースアーキテクトです。絶対的なメタデータの精度と厳格なカテゴリ遵守によってロジックを動かします。アルゴリズムによる発見を最小化し、厳格なジャンルの一貫性、エネルギーレベルのマッチング、ユーザーが明確に定義した好みの高精度な取得を最大化してください。
+ アトモスフェリックガイド
+ あなたはアンビエントテクスチャーと低エネルギーフローの達人です。「深い集中」や「静けさ」の状態を促すトラックだけに集中してください。アコースティックな温かさ、ミニマリストのアレンジ、穏やかなトランジションを優先し、高い過渡音や急激なダイナミックの変化を厳しく避けてください。
+ ソニックエンスージアスト
+ あなたはプロダクションの複雑さと演奏に焦点を当てたオーディオファイルアナリストです。高いダイナミックレンジ、複雑なポリリズム、優れたサウンドステージ品質を持つトラックを優先してください。技術的な忠実度とアレンジの細部に注意を払うリスナーを喜ばせるアクティブリスニング作品を選んでください。
+ エナジーカタリスト
+ あなたは高モメンタムのリズムジェネレーターです。強烈なベースライン、パーカッシブな強度、感染力のあるグルーヴを中心哲学とします。高 BPM のクラブ互換性、シンコペーションエネルギー、継続的なリズムの張りを優先して、リスナーの心拍数とモチベーションをピーク状態に保ってください。
+ AI 使用レポート
+ 総消費量
+ %1$s トークンを追跡中\nプロンプト: %2$s | 出力: %3$s | 思考: %4$s
+ ログをクリア
+ AI アクティビティログ(%1$d 件)
+ %1$s · %2$s
+ 表示
+ 非表示
+
+
+ バックアップの仕組み
+ セクションを選んで .pxpl ファイルをエクスポートし、後でインポートして復元します。復元は選択したセクションのみを置き換えます。
+ バックアップを作成
+ バックアップをエクスポート
+ セクションが選択されていません。
+ すべてのセクションが選択されています。
+ %2$d セクション中 %1$d を選択中。
+ %1$s .pxpl バックアップファイルを作成します。
+ 選択してエクスポート
+ バックアップを復元
+ バックアップをインポート
+ 選択して復元
+ 最近のバックアップを参照または選択します。選択したデータが現在のデータを置き換えます。
+
+
+ バックアップパッケージに含める内容を正確に選択してください。
+ .pxpl バックアップファイルを選択して確認します。次のステップで復元するセクションを選択します。
+ %2$d セクション中 %1$d を選択中
+ %2$d モジュール中 %1$d を選択中
+ 最近のバックアップ
+ 最近のバックアップはありません
+ 以前にインポートしたバックアップがここに表示されます。
+ %1$d エントリー · 現在のデータを置き換えます
+ .pxpl をエクスポート
+ 選択を復元
+ 転送中…
+ PixelPlayer_Backup_%1$d.pxpl
+ バックアップを作成中
+ バックアップを復元中
+ %1$d%%
+ %1$s • %2$s
+ エクスポート中
+ インポート中
+ 復元中
+ 履歴から削除
+ 確認中…
+ ファイルを参照
+ ステップ %1$d / %2$d
+ モジュールを復元
+ バックアップの詳細
+ 作成日
+ アプリバージョン
+ スキーマ
+ デバイス
+ 不明
+ · %1$s
+ %1$d モジュール · v%2$s · スキーマ v%3$d
+ \?
+ すべて選択
+ 選択をクリア
+
+
+ 無効なバックアップ: %1$s
+ 復元を準備中
+ 復元タスクを開始しています。
+ バックアップを準備中
+ バックアップタスクを開始しています。
+ バックアップを正常に復元しました
+ 一部の未解決の問題がありましたが復元は完了しました。
+ 復元を完了できませんでした: %1$s
+ 復元に失敗しました: %1$s
+ データを正常にエクスポートしました
+ エクスポートに失敗しました: %1$s
+ データを正常に復元しました
+ 未解決の問題で復元が完了しました。失敗: %1$s
+ v%1$d
+ %1$s %2$s
+
+
+ 実験的機能
+ 試験的
+ プレイヤー UI 読み込みの実験とトグル。
+ セットアップフローをテスト
+ テスト用にオンボーディングのセットアップ画面を起動します。
+ メンテナンス
+ デイリーミックスの強制再生成
+ デイリーミックスプレイリストをすぐに再作成します。
+ デイリーミックスを再生成
+ デイリーミックスを再生成しますか?
+ 現在のミックスを破棄して、最近のリスニング習慣に基づいて新しいミックスを生成します。
+ デイリーミックスの再生成を開始しました
+ 統計の強制再生成
+ キャッシュをクリアして再生統計を再計算します。
+ 再生成
+ 処理中…
+ 統計を再生成
+ 統計を再生成しますか?
+ 統計キャッシュをクリアして、データベース履歴から強制的に再計算します。
+ 統計の再生成を開始しました
+ アルバムパレットの強制再生成
+ すべてのアルバムアートのキャッシュされたパレットバリアントを再構築するか、特定の 1 枚を更新します。
+ すべて再生成
+ すべてのアルバムパレットを再生成しますか?
+ キャッシュされたテーマデータをクリアして、%1$d 枚のユニークなアルバムアートのすべてのパレットスタイルを再構築します。
+ 再生成中…
+ アルバムパレットを再生成中…
+ %1$d 枚のユニークなアルバムアートのキャッシュされたパレットバリアントを再構築中です。大きなライブラリでは時間がかかることがあります。
+ %1$d / %2$d 完了
+ %1$d 枚のアルバムアートパレットを再生成しました
+ %2$d 枚中 %1$d 枚のアルバムアートパレットを再生成しました
+ 曲を選択
+ 曲を選択するとキャッシュされたテーマデータをクリアして、アルバムアートからすべてのパレットスタイルを再生成します。
+ タイトル、アーティスト、アルバムで検索
+ 検索に一致する曲がありません。
+ アルバムアートのある曲が見つかりませんでした。
+ パレットを再生成中…
+ %1$s のパレットを再生成しました
+ %1$s のパレットを再生成できませんでした
+ 診断
+ テストクラッシュを発生させる
+ クラッシュレポートシステムをテストするためにクラッシュをシミュレートします。
+ 開発者オプションからテストクラッシュを発生させました — これはクラッシュレポートシステムをテストするための意図的な操作です
+
+
+ 試験的
+ プレイヤー UI 読み込みの調整
+ アニメーション歌詞(ハイエンド端末向け)
+ 歌詞にスプリングアニメーションとビジュアル効果を使用します。ローエンド端末ではフレームドロップが発生する場合があります。
+ 歌詞のブラー効果
+ 非アクティブな歌詞に被写界深度ブラーを適用します。
+ ブラー強度
+ ブラー効果の強さを調整します。
+ %1$.1f倍
+ ステップ 1 · 遅延する対象を選択
+ すべてを遅延
+ シートの背景が完全に展開されるまでプレイヤーのコンテンツ全体を保持します。
+ アルバムカルーセル
+ シートが展開されるまでアルバムアートとカルーセルを遅延します。
+ 曲のメタデータ
+ タイトル、アーティスト、歌詞/キューのアクションを遅延します。
+ 進行バー
+ 展開完了までタイムラインと時刻ラベルを遅延します。
+ 再生コントロール
+ 再生/一時停止、シーク、お気に入りコントロールを遅延します。
+ 遅延するコンポーネントがすべてアクティブです。「すべてを遅延」を無効にして各パーツをカスタマイズします。
+ ステップ 2 · プレースホルダーの動作を設定
+ 遅延項目にプレースホルダーを使用
+ コンポーネントが展開を待つ間、軽量なプレースホルダーを描画してレイアウトを安定させます。
+ ステップ 3 · プレースホルダーから実コンテンツに切り替えるタイミングを選択
+ モードを 1 つ選択してください。閾値モードはスライダーを使用します。ドラッグリリースモードはシートジェスチャーを離すまで待機します。
+ トリガーモードを解除するには遅延コンポーネントを少なくとも 1 つ有効にしてください。
+ 閾値
+ 展開率を使用します。
+ ドラッグリリース
+ ジェスチャーを離した後のみ切り替えます。
+ 展開閾値
+ 遅延コンポーネントが表示されるまでにシートがどれだけ展開している必要があるか。
+ コンテンツは %1$d%% 展開時に表示されます
+ プレイヤーを閉じるときにも適用
+ 折りたたむ際に閉じる閾値を使ってプレースホルダーに戻します。
+ 閉じる閾値
+ プレースホルダーが再び表示されるまでにどれだけ折りたたまれている必要があるか。
+ %1$d%% 折りたたみ後にプレースホルダーが表示されます
+ ドラッグリリースモードは閾値と閉じる動作をバイパスします。切り替えはシートのドラッグジェスチャーが終了したときのみ発生します。
+ プレースホルダーを透明にする
+ プレースホルダーはレイアウトスペースを保持したまま見えなくなります。
+ 画質
+ アルバムアートの解像度
+ 低(256px)- パフォーマンス重視
+ 中(512px)- バランス型
+ 高(800px)- 最高品質
+ オリジナル - 最大品質
+
+
+ 再生には確認が必要です
+ 再生の準備ができています
+ --
+ フォーマット
+ HW デコーダー
+ ローカル曲
+ ローカル音楽ストレージ
+ 音楽サイズ
+ %1$d 曲(ローカル)
+ 利用可能
+ %1$s 合計
+ 音楽の使用量
+ デバイス使用中
+ %1$d%%
+ <1%
+ %1$d 曲(クラウド)
+ %1$d ファイルは読み取り不可
+ 再生パス
+ はい
+ いいえ
+ サンプルレート
+ %1$d Hz
+ %1$d フレーム/バッファ
+ Hi-Fi PCM Float
+ 32 ビット float 出力パス
+ 低レイテンシーサポート
+ プロオーディオサポート
+ メモリ
+ %1$s 中利用可能
+ オフロード対応フォーマット
+ ハードウェアオフロードをサポートする圧縮フォーマットは報告されませんでした。
+ 他 %1$d 件
+ 検出された出力
+ 内蔵出力
+ Bluetooth オーディオ
+ USB オーディオ
+ 有線オーディオ
+ デジタル出力
+ その他の出力
+ Android から出力ルートは報告されませんでした。
+ ExoPlayer エンジン
+ %1$s レンダラー
+ フォーマット互換性
+ %1$d 対応トラック
+ %1$d 不明なフォーマット
+ デコーダーが報告されません
+ ハードウェアデコーダー
+ ソフトウェアデコーダー
+ オフロード
+ ライブラリ内 %1$d 件
+ 互換性の確認結果
+ 大きな非互換性はありません
+ インデックスされたトラックはこのデバイスで Android が報告するデコーダーと一致しています。
+ %1$d 件のトラックはネイティブデコードできない可能性があります
+ 確認が必要なフォーマット: %1$s。
+ %1$d 件のローカルトラックはリサンプリングされる可能性があります
+ ライブラリは現在の出力サンプルレートを超える %1$d Hz に達しています。
+ %1$d 件のトラックはメタデータが不明です
+ ライブラリを完全にリスキャンすると MIME、ビットレート、サンプルレートの欠損データを補完できます。
+ デバイス情報
+ メーカー
+ モデル
+ ブランド
+ デバイス
+ Android バージョン
+ SDK バージョン
+ ハードウェア
+ パフォーマンスレポート
+ 再生やスキャンのラグを分類するのに役立つ共有可能な診断レポートを生成します。デバイス、ライブラリ、タイミングデータのみを含み、ファイルパス、タイトル、アーティストは含まれません。
+ レポートを生成
+ 再生成
+ コピー
+ 共有
+ レポートをクリップボードにコピーしました
+ PixelPlayer パフォーマンスレポート
+ 高度なパフォーマンス診断
+ デフォルトではオフです。ベータのトラブルシューティング用に短いラグタイムラインを記録します。
+ %1$s まで有効
+ 今ラグをマーク
+ ラグの瞬間をマークしました
+
+
+ 接続済みアカウント
+ リンクされたプロバイダーを管理して各連携をコントロールします。
+ リンク済みサービス
+ アクティブ
+ 利用可能
+ 近日公開
+ 接続済み
+ 近日公開
+ サービスを開く
+ ログアウト中…
+ リンク済みアカウントがまだありません
+ プロバイダーを接続するとこの画面で管理できます。
+ %1$s に接続
+ %1$s(近日公開)
+ Google Drive は近日公開予定です。
+ 現在この画面を開けません。
+
+
+ このアプリについて
+ PixelPlayer
+ コミュニティと共に作られたオープンソースの音楽プレイヤー。
+ バージョン v%1$s
+ オープンソース
+ コミュニティファースト
+ Material 3 Expressive
+ 現在コントリビューターが見つかりません。後でもう一度お試しください。
+ メンテナー
+ PixelPlayer の開発者。
+ コミュニティスポットライト
+ 大きな貢献をしたコラボレーターへの感謝。
+ オープンソースコントリビューター
+ GitHub からのライブコントリビューターリスト。
+ %1$d 回のコントリビューション
+ GitHub
+ リポジトリ
+ Telegram
+ サポート
+ GitHub リポジトリを開く
+ Telegram コミュニティに参加
+ GitHub プロフィールを開く
+ Telegram を開く
+ %1$s のアバター
+ %1$s のアイコン
+
+
+ 音量
+ 音量ゼロで一時停止
+ 音量が 0 に設定されたとき、再生を自動的に一時停止します。
+
diff --git a/app/src/main/res/values-ja/strings_widget.xml b/app/src/main/res/values-ja/strings_widget.xml
new file mode 100644
index 000000000..c0d6f4787
--- /dev/null
+++ b/app/src/main/res/values-ja/strings_widget.xml
@@ -0,0 +1,17 @@
+
+
+ サイズに合わせて自動調整するウィジェット
+ コンパクトなプレイヤーバー
+ シャッフルとリピートを含むフルコントロール
+ ミニマリストな正方形プレイヤー
+
+ タップして開く
+ アルバムアート
+ アルバムアートのプレースホルダー
+
+ タップして再生
+ 曲のタイトル
+ アーティスト
+
+ 進行バー、%1$d%%
+
diff --git a/app/src/main/res/values-ko/strings_home_screen.xml b/app/src/main/res/values-ko/strings_home_screen.xml
index ece00d63f..f831b8995 100644
--- a/app/src/main/res/values-ko/strings_home_screen.xml
+++ b/app/src/main/res/values-ko/strings_home_screen.xml
@@ -9,9 +9,9 @@
클라우드 계정에서 음악 스트리밍
- Beta 0.7.0
+ Beta 0.7.5
β
- PixelPlayer 0.7.0-beta에 오신 것을 환영합니다
+ PixelPlayer 0.7.5-beta에 오신 것을 환영합니다
버그, 충돌 또는 실험적 기능이 포함되어 있을 수 있는 베타 빌드를 사용 중입니다. 문제를 보고하여 앱을 개선할 수 있도록 도와주세요.
주요 변경 사항
버그, 충돌 또는 불완전한 기능이 예기치 않게 발생할 수 있습니다.
@@ -273,4 +273,4 @@
%1$d곡
%1$d곡
%1$d주차
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml
index bcc63f057..61fff2f6b 100644
--- a/app/src/main/res/values-ko/strings_settings.xml
+++ b/app/src/main/res/values-ko/strings_settings.xml
@@ -174,6 +174,7 @@
한국어
노르웨이어 (Bokmål)
터키어
+ 일본어
앱 테마
밝은 테마, 어두운 테마 또는 시스템 설정 따르기 중에서 선택하세요.
밝은 테마
@@ -633,8 +634,17 @@
오픈 소스 기여자
GitHub의 실시간 기여자 목록입니다.
기여 %1$d회
+ GitHub
+ 저장소
+ Telegram
+ 지원
+ GitHub 저장소 열기
+ Telegram 커뮤니티 가입
GitHub 프로필 열기
Telegram 열기
%1$s 아바타
%1$s 아이콘
+ 볼륨
+ 볼륨이 0이 되면 일시정지
+ 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다
diff --git a/app/src/main/res/values-nb/strings_home_screen.xml b/app/src/main/res/values-nb/strings_home_screen.xml
index e1f64c77a..ee172a5bc 100644
--- a/app/src/main/res/values-nb/strings_home_screen.xml
+++ b/app/src/main/res/values-nb/strings_home_screen.xml
@@ -9,9 +9,9 @@
Strøm musikk fra dine skykontoer
- Beta 0.7.0
+ Beta 0.7.5
β
- Velkommen til PixelPlayer 0.7.0-beta
+ Velkommen til PixelPlayer 0.7.5-beta
Du bruker en betaversjon som kan inneholde feil, krasj eller eksperimentelle funksjoner. Hjelp oss å forbedre ved å rapportere problemer.
Hva du kan forvente
Feil, krasj eller uferdige funksjoner kan oppstå uventet.
@@ -274,4 +274,4 @@
%1$d sang
%1$d sanger
Uke %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml
index fd101cb66..65742a63d 100644
--- a/app/src/main/res/values-nb/strings_settings.xml
+++ b/app/src/main/res/values-nb/strings_settings.xml
@@ -174,6 +174,7 @@
Koreansk
Norsk bokmål
Tyrkisk
+ Japansk
App-tema
Bytt mellom lyst, mørkt eller følg systemets utseende.
Lyst tema
@@ -633,8 +634,17 @@
Bidragsytere til åpen kildekode
Live bidragsyterliste fra GitHub.
%1$d bidrag.
+ GitHub
+ Kodelager
+ Telegram
+ Støtte
+ Åpne GitHub-kodelager
+ Bli med i Telegram-samfunnet
Åpne GitHub-profil
Åpne Telegram
Avatar av %1$s
Ikon av %1$s
+ Volum
+ Sett på pause når volumet er null
+ Sett automatisk avspillingen på pause når volumet settes til 0
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings_home_screen.xml b/app/src/main/res/values-ru/strings_home_screen.xml
index b14b73cd7..8e0229903 100644
--- a/app/src/main/res/values-ru/strings_home_screen.xml
+++ b/app/src/main/res/values-ru/strings_home_screen.xml
@@ -9,9 +9,9 @@
Слушайте музыку из своих облачных аккаунтов
- Бета 0.7.0
+ Бета 0.7.5
β
- Добро пожаловать в PixelPlayer 0.7.0-beta
+ Добро пожаловать в PixelPlayer 0.7.5-beta
Вы используете бета-версию, которая может содержать ошибки, сбои или экспериментальные функции. Помогите нам улучшить приложение, сообщая о проблемах.
Чего ожидать
Ошибки, сбои или незавершённые функции могут возникать неожиданно.
@@ -276,4 +276,4 @@
%1$d песня
%1$d песен
Неделя %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml
index 997331e07..f3278bfd3 100644
--- a/app/src/main/res/values-ru/strings_settings.xml
+++ b/app/src/main/res/values-ru/strings_settings.xml
@@ -174,6 +174,7 @@
Корейский
Норвежский (Bokmål)
Турецкий
+ Японский
Тема приложения
Светлая, тёмная тема или настройки системы.
Светлая тема
@@ -633,8 +634,17 @@
Участники open source
Актуальный список участников с GitHub.
%1$d вклад.
+ GitHub
+ Репозиторий
+ Telegram
+ Поддержка
+ Открыть репозиторий GitHub
+ Присоединиться к сообществу Telegram
Открыть профиль GitHub
Открыть Telegram
Аватар %1$s
Значок %1$s
+ Громкость
+ Пауза при нулевой громкости
+ Автоматически приостанавливать воспроизведение, когда громкость равна 0
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 2f0dfdccb..5b1dd61ba 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -125,4 +125,5 @@
Albümü oynat
Albümü karışık oynat
%1$s için albüm kapağı
+
diff --git a/app/src/main/res/values-tr/strings_home_screen.xml b/app/src/main/res/values-tr/strings_home_screen.xml
index 49de86173..2bf26ab40 100644
--- a/app/src/main/res/values-tr/strings_home_screen.xml
+++ b/app/src/main/res/values-tr/strings_home_screen.xml
@@ -9,9 +9,9 @@
Bulut hesaplarınızdan müzik akışı yapın
- Beta 0.7.0
+ Beta 0.7.5
β
- PixelPlayer 0.7.0-beta\'ya Hoş Geldiniz
+ PixelPlayer 0.7.5-beta\'ya Hoş Geldiniz
Hata, çökme veya deneysel özellikler içerebilecek bir beta yapısı kullanıyorsunuz. Sorunları bildirerek geliştirmemize yardımcı olun.
Ne beklemeli
Beklenmedik hatalar, çökmeler veya tamamlanmamış özellikler olabilir.
@@ -274,4 +274,4 @@
%1$d Şarkı
%1$d Şarkı
%1$d. Hafta
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml
index 1f4dc593c..1604e150f 100644
--- a/app/src/main/res/values-tr/strings_settings.xml
+++ b/app/src/main/res/values-tr/strings_settings.xml
@@ -174,6 +174,7 @@
Korece
Norveççe (Bokmål)
Türkçe
+ Japonca
Uygulama Teması
Açık, koyu tema arasında geçiş yapın veya sistem görünümünü takip edin.
Açık Tema
@@ -633,8 +634,17 @@
Açık kaynak katkıda bulunanlar
GitHub\'dan canlı katkıda bulunanlar listesi.
%1$d katkı
+ GitHub
+ Depo
+ Telegram
+ Destek
+ GitHub deposunu aç
+ Telegram topluluğuna katıl
GitHub profilini aç
Telegram\'ı aç
%1$s avatarı
%1$s simgesi
+ Ses
+ Ses sıfıra ulaştığında duraklat
+ Ses seviyesi 0\'a ayarlandığında oynatmayı otomatik olarak duraklat
diff --git a/app/src/main/res/values-zh-rCN/strings_home_screen.xml b/app/src/main/res/values-zh-rCN/strings_home_screen.xml
index 78a2f155d..46a70f806 100644
--- a/app/src/main/res/values-zh-rCN/strings_home_screen.xml
+++ b/app/src/main/res/values-zh-rCN/strings_home_screen.xml
@@ -9,9 +9,9 @@
从您的云端账户串流音乐
- Beta 0.7.0
+ Beta 0.7.5
β
- 欢迎使用 PixelPlayer 0.7.0-beta
+ 欢迎使用 PixelPlayer 0.7.5-beta
您正在使用的是可能包含错误、崩溃或实验性功能的测试版本。请报告问题以帮助我们改进。
预期情况
可能会意外出现错误、崩溃或未完成的功能。
@@ -274,4 +274,4 @@
%1$d 首歌曲
%1$d 首歌曲
第 %1$d 周
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml
index 0822b25e0..eb4a300a5 100644
--- a/app/src/main/res/values-zh-rCN/strings_settings.xml
+++ b/app/src/main/res/values-zh-rCN/strings_settings.xml
@@ -174,6 +174,7 @@
韩语
挪威语(Bokmål)
土耳其语
+ 日语
应用主题
在浅色、深色之间切换,或跟随系统外观。
浅色主题
@@ -633,8 +634,17 @@
开源贡献者
来自 GitHub 的实时贡献者名单。
%1$d 次贡献
+ GitHub
+ 代码仓库
+ Telegram
+ 支持
+ 打开 GitHub 仓库
+ 加入 Telegram 社区
打开 GitHub 个人资料
打开 Telegram
%1$s 的头像
%1$s 的图标
+ 音量
+ 音量为零时暂停
+ 当音量设置为 0 时自动暂停播放
diff --git a/app/src/main/res/values/strings_home_screen.xml b/app/src/main/res/values/strings_home_screen.xml
index b60cde200..2a7e3ad80 100644
--- a/app/src/main/res/values/strings_home_screen.xml
+++ b/app/src/main/res/values/strings_home_screen.xml
@@ -9,9 +9,9 @@
Stream music from your cloud accounts
- Beta 0.7.0
+ Beta 0.7.5
β
- Welcome to PixelPlayer 0.7.0-beta
+ Welcome to PixelPlayer 0.7.5-beta
You\'re using a beta build that may contain bugs, crashes, or experimental features. Help us improve by reporting issues.
What to expect
Bugs, crashes, or incomplete features may occur unexpectedly.
@@ -274,4 +274,4 @@
%1$d Song
%1$d Songs
Week %1$d
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml
index 298046dec..ffd033ab4 100644
--- a/app/src/main/res/values/strings_settings.xml
+++ b/app/src/main/res/values/strings_settings.xml
@@ -174,6 +174,7 @@
Korean
Norwegian (Bokmål)
Türkçe
+ Japanese
App Theme
Switch between light, dark, or follow system appearance.
Light Theme
@@ -633,8 +634,17 @@
Open source contributors
Live contributor list from GitHub.
%1$d contrib.
+ GitHub
+ Repository
+ Telegram
+ Support
+ Open GitHub repository
+ Join Telegram community
Open GitHub profile
Open Telegram
Avatar of %1$s
Icon of %1$s
+ Volume
+ Pause when volume reaches zero
+ Automatically pause playback when the volume is set to 0
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index 12f03ebe0..933ea17f9 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -3,4 +3,5 @@
-
\ No newline at end of file
+
+
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt
index 6c75bf38d..adc8690cb 100644
--- a/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt
@@ -11,6 +11,34 @@ import java.nio.file.Files
class UserPreferencesRepositoryTest {
+ @Test
+ fun `default artist delimiters avoid common characters inside artist names`() {
+ assertEquals(listOf(";"), UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS)
+ }
+
+ @Test
+ fun `artistDelimitersFlow normalizes stored legacy defaults`() = runTest {
+ val tempDir = Files.createTempDirectory("user-preferences-repository-test")
+ try {
+ val repository = UserPreferencesRepository(
+ dataStore = PreferenceDataStoreFactory.create(
+ scope = backgroundScope,
+ produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }
+ ),
+ json = Json
+ )
+
+ repository.setArtistDelimiters(listOf("/", ";", ",", "+", "&"))
+
+ assertEquals(
+ UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
+ repository.artistDelimitersFlow.first()
+ )
+ } finally {
+ tempDir.toFile().deleteRecursively()
+ }
+ }
+
@Test
fun `clearPreferencesExceptKeys preserves initial setup completion`() = runTest {
val tempDir = Files.createTempDirectory("user-preferences-repository-test")
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt
new file mode 100644
index 000000000..bfd35a313
--- /dev/null
+++ b/app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt
@@ -0,0 +1,23 @@
+package com.theveloper.pixelplay.data.stream
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class CloudMusicUtilsTest {
+
+ @Test
+ fun `parseArtistNames preserves common punctuation in artist names by default`() {
+ assertEquals(listOf("W&W"), CloudMusicUtils.parseArtistNames("W&W"))
+ assertEquals(listOf("AC/DC"), CloudMusicUtils.parseArtistNames("AC/DC"))
+ assertEquals(listOf("Lost & Found"), CloudMusicUtils.parseArtistNames("Lost & Found"))
+ assertEquals(listOf("Black Country, New Road"), CloudMusicUtils.parseArtistNames("Black Country, New Road"))
+ }
+
+ @Test
+ fun `parseArtistNames still splits explicit semicolon artists`() {
+ assertEquals(
+ listOf("Artist One", "Artist Two"),
+ CloudMusicUtils.parseArtistNames("Artist One; Artist Two")
+ )
+ }
+}
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtilsTest.kt
index b535a2cc1..fee88c597 100644
--- a/app/src/test/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtilsTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/data/worker/AlbumGroupingUtilsTest.kt
@@ -113,6 +113,24 @@ class AlbumGroupingUtilsTest {
assertThat(displayArtist).isEqualTo("The Weeknd")
}
+ @Test
+ fun `chooseAlbumDisplayArtist uses primary parsed artist for feature-heavy albums`() {
+ val songs = listOf(
+ testSong(artistName = "Gorillaz feat. Stevie Nicks", albumArtist = null),
+ testSong(artistName = "Gorillaz feat. Thundercat", albumArtist = null),
+ testSong(artistName = "Gorillaz feat. Tame Impala", albumArtist = null)
+ )
+
+ val displayArtist = chooseAlbumDisplayArtist(
+ songs = songs,
+ preferAlbumArtist = false,
+ artistDelimiters = listOf(";"),
+ wordDelimiters = listOf("feat.")
+ )
+
+ assertThat(displayArtist).isEqualTo("Gorillaz")
+ }
+
@Test
fun `chooseAlbumDisplayArtist prefers album artist when grouping is on`() {
val songs = listOf(
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/worker/ArtistParsingUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/worker/ArtistParsingUtilsTest.kt
index 937fab122..ca0cfa610 100644
--- a/app/src/test/java/com/theveloper/pixelplay/data/worker/ArtistParsingUtilsTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/data/worker/ArtistParsingUtilsTest.kt
@@ -2,9 +2,50 @@ package com.theveloper.pixelplay.data.worker
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
+import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
class ArtistParsingUtilsTest {
+ @Test
+ fun `default delimiters preserve ampersand slash comma and plus inside artist names`() {
+ assertEquals(
+ listOf("W&W"),
+ collectArtistNames(
+ rawArtistName = "W&W",
+ title = "Rave Culture",
+ artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
+ wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
+ )
+ )
+ assertEquals(
+ listOf("AC/DC"),
+ collectArtistNames(
+ rawArtistName = "AC/DC",
+ title = "Back In Black",
+ artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
+ wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
+ )
+ )
+ assertEquals(
+ listOf("Lost & Found"),
+ collectArtistNames(
+ rawArtistName = "Lost & Found",
+ title = "Found",
+ artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
+ wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
+ )
+ )
+ assertEquals(
+ listOf("Black Country, New Road"),
+ collectArtistNames(
+ rawArtistName = "Black Country, New Road",
+ title = "Track X",
+ artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS,
+ wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS
+ )
+ )
+ }
+
@Test
fun `choosePreferredArtistName prefers media store when it contains more artists`() {
val result =
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