diff --git a/.gitignore b/.gitignore index 052d70a6d..0c9baa034 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ CLAUDE.md markdown.xml .vscode/ .agents/ +.claude/ \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 34dd762b1..0bdf66b9e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -275,7 +275,7 @@ dependencies { // UI Utilities & Extra implementation(libs.timber) - implementation(libs.generativeai) + implementation("com.google.genai:google-genai:${libs.versions.googleGenai.get()}") implementation(libs.smooth.corner.rect.android.compose) implementation(libs.reorderables) implementation(libs.codeview) @@ -294,6 +294,22 @@ dependencies { exclude(group = "androidx.compose.ui") } + // Local AI: TensorFlow Lite + implementation(libs.tensorflow.lite) + implementation(libs.tensorflow.lite.support) { + exclude(group = "com.google.ai.edge.litert", module = "litert-support-api") + } + implementation(libs.tensorflow.lite.gpu) + implementation(libs.tensorflow.lite.task.text) { + // Prevent duplicate runtime classes from mixing LiteRT and tensorflow-lite-api artifacts. + exclude(group = "org.tensorflow", module = "tensorflow-lite-api") + exclude(group = "org.tensorflow", module = "tensorflow-lite-support-api") + } + + // Local AI: ML Kit + implementation(libs.mlkit.translate) + implementation(libs.mlkit.language.id) + // Projects implementation(project(":shared")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1d1420955..00749b1ab 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -213,3 +213,17 @@ public static int d(...); public static int i(...); } +# TensorFlow Lite +-keep class org.tensorflow.lite.** { *; } +-dontwarn org.tensorflow.lite.** + +# TensorFlow Lite GPU Delegate +-keep class org.tensorflow.lite.gpu.GpuDelegateFactory$Options { *; } +-keep class org.tensorflow.lite.gpu.GpuDelegateFactory$Options$GpuBackend { *; } + +# TensorFlow Lite Support +-keep class org.tensorflow.lite.support.label.Category { *; } +-keep class org.tensorflow.lite.support.** { *; } + +# TensorFlow Lite Task +-keep class org.tensorflow.lite.task.** { *; } \ No newline at end of file diff --git a/app/src/main/baseline-prof.txt b/app/src/main/baseline-prof.txt index ff6b9d94e..be2bb045e 100644 --- a/app/src/main/baseline-prof.txt +++ b/app/src/main/baseline-prof.txt @@ -30668,7 +30668,6 @@ SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC Lcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl; SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;->-$$Nest$fgetapplicationContextModule(Lcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;)Ldagger/hilt/android/internal/modules/ApplicationContextModule; SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;->(Ldagger/hilt/android/internal/modules/ApplicationContextModule;)V -SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;->aiMetadataGenerator()Lcom/theveloper/pixelplay/data/ai/AiMetadataGenerator; SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;->aiPlaylistGenerator()Lcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator; SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;->aiUsageDao()Lcom/theveloper/pixelplay/data/database/AiUsageDao; SPLcom/theveloper/pixelplay/DaggerPixelPlayApplication_HiltComponents_SingletonC$SingletonCImpl;->hiltWorkerFactory()Landroidx/hilt/work/HiltWorkerFactory; @@ -31184,9 +31183,6 @@ SPLcom/theveloper/pixelplay/data/DailyMixManager$readEngagements$1;->(Lcom SPLcom/theveloper/pixelplay/data/DailyMixManager$readEngagements$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Lcom/theveloper/pixelplay/data/DailyMixManager$statsType$1; SPLcom/theveloper/pixelplay/data/DailyMixManager$statsType$1;->()V -Lcom/theveloper/pixelplay/data/ai/AiMetadataGenerator; -SPLcom/theveloper/pixelplay/data/ai/AiMetadataGenerator;->()V -SPLcom/theveloper/pixelplay/data/ai/AiMetadataGenerator;->(Lcom/theveloper/pixelplay/data/ai/AiOrchestrator;Lkotlinx/serialization/json/Json;)V Lcom/theveloper/pixelplay/data/ai/AiNotificationManager; SPLcom/theveloper/pixelplay/data/ai/AiNotificationManager;->()V SPLcom/theveloper/pixelplay/data/ai/AiNotificationManager;->(Landroid/content/Context;)V @@ -31194,12 +31190,12 @@ SPLcom/theveloper/pixelplay/data/ai/AiNotificationManager;->createChannel()V Lcom/theveloper/pixelplay/data/ai/AiNotificationManager$Companion; SPLcom/theveloper/pixelplay/data/ai/AiNotificationManager$Companion;->()V SPLcom/theveloper/pixelplay/data/ai/AiNotificationManager$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Lcom/theveloper/pixelplay/data/ai/AiOrchestrator; -SPLcom/theveloper/pixelplay/data/ai/AiOrchestrator;->()V -SPLcom/theveloper/pixelplay/data/ai/AiOrchestrator;->(Lcom/theveloper/pixelplay/data/preferences/AiPreferencesRepository;Lcom/theveloper/pixelplay/data/ai/provider/AiClientFactory;Lcom/theveloper/pixelplay/data/database/AiCacheDao;Lcom/theveloper/pixelplay/data/database/AiUsageDao;Lcom/theveloper/pixelplay/data/ai/AiSystemPromptEngine;Lkotlinx/coroutines/CoroutineScope;)V +Lcom/theveloper/pixelplay/data/ai/AiHandler; +SPLcom/theveloper/pixelplay/data/ai/AiHandler;->()V +SPLcom/theveloper/pixelplay/data/ai/AiHandler;->(Lcom/theveloper/pixelplay/data/preferences/AiPreferencesRepository;Lcom/theveloper/pixelplay/data/ai/provider/AiClientFactory;Lcom/theveloper/pixelplay/data/database/AiCacheDao;Lcom/theveloper/pixelplay/data/database/AiUsageDao;Lcom/theveloper/pixelplay/data/ai/AiSystemPromptEngine;Lkotlinx/coroutines/CoroutineScope;)V Lcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator; SPLcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator;->()V -SPLcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator;->(Lcom/theveloper/pixelplay/data/DailyMixManager;Lcom/theveloper/pixelplay/data/ai/AiOrchestrator;Lcom/theveloper/pixelplay/data/ai/UserProfileDigestGenerator;Lcom/theveloper/pixelplay/data/preferences/AiPreferencesRepository;Lkotlinx/serialization/json/Json;)V +SPLcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator;->(Lcom/theveloper/pixelplay/data/DailyMixManager;Lcom/theveloper/pixelplay/data/ai/AiHandler;Lcom/theveloper/pixelplay/data/ai/UserProfileDigestGenerator;Lcom/theveloper/pixelplay/data/preferences/AiPreferencesRepository;Lcom/theveloper/pixelplay/data/stats/PlaybackStatsRepository;Lkotlinx/serialization/json/Json;)V Lcom/theveloper/pixelplay/data/ai/AiSystemPromptEngine; SPLcom/theveloper/pixelplay/data/ai/AiSystemPromptEngine;->()V SPLcom/theveloper/pixelplay/data/ai/AiSystemPromptEngine;->()V @@ -37714,15 +37710,13 @@ Lcom/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel_HiltModules_K SPLcom/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V Lcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder; SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->()V -SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->(Landroid/content/Context;Lcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator;Lcom/theveloper/pixelplay/data/ai/AiMetadataGenerator;Lcom/theveloper/pixelplay/data/DailyMixManager;Lcom/theveloper/pixelplay/data/preferences/PlaylistPreferencesRepository;Lcom/theveloper/pixelplay/presentation/viewmodel/DailyMixStateHolder;Lcom/theveloper/pixelplay/data/ai/AiNotificationManager;)V +SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->(Landroid/content/Context;Lcom/theveloper/pixelplay/data/ai/AiPlaylistGenerator;Lcom/theveloper/pixelplay/data/DailyMixManager;Lcom/theveloper/pixelplay/data/preferences/PlaylistPreferencesRepository;Lcom/theveloper/pixelplay/presentation/viewmodel/DailyMixStateHolder;Lcom/theveloper/pixelplay/data/ai/AiNotificationManager;Lcom/theveloper/pixelplay/data/ai/AiHandler;)V SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->getAiError()Lkotlinx/coroutines/flow/StateFlow; -SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->getAiMetadataSuccess()Lkotlinx/coroutines/flow/StateFlow; SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->getAiStatus()Lkotlinx/coroutines/flow/StateFlow; SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->getAiSuccess()Lkotlinx/coroutines/flow/StateFlow; SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->getShowAiPlaylistSheet()Lkotlinx/coroutines/flow/StateFlow; SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->initialize(Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;)V SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->isGeneratingAiPlaylist()Lkotlinx/coroutines/flow/StateFlow; -SPLcom/theveloper/pixelplay/presentation/viewmodel/AiStateHolder;->isGeneratingMetadata()Lkotlinx/coroutines/flow/StateFlow; Lcom/theveloper/pixelplay/presentation/viewmodel/AiUiSnapshot; SPLcom/theveloper/pixelplay/presentation/viewmodel/AiUiSnapshot;->(ZZLjava/lang/String;Ljava/lang/String;Z)V SPLcom/theveloper/pixelplay/presentation/viewmodel/AiUiSnapshot;->isGeneratingAiMetadata()Z diff --git a/app/src/main/java/com/theveloper/pixelplay/data/DailyMixManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/DailyMixManager.kt index 6e3219b6a..7afba992f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/DailyMixManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/DailyMixManager.kt @@ -45,7 +45,9 @@ class DailyMixManager @Inject constructor( data class SongEngagementStats( val playCount: Int = 0, val totalPlayDurationMs: Long = 0L, - val lastPlayedTimestamp: Long = 0L + val lastPlayedTimestamp: Long = 0L, + val skipCount: Int = 0, + val completedCount: Int = 0 ) init { @@ -118,7 +120,9 @@ class DailyMixManager @Inject constructor( entity.songId to SongEngagementStats( playCount = entity.playCount, totalPlayDurationMs = entity.totalPlayDurationMs, - lastPlayedTimestamp = entity.lastPlayedTimestamp + lastPlayedTimestamp = entity.lastPlayedTimestamp, + skipCount = entity.skipCount, + completedCount = entity.completedCount ) } } @@ -237,7 +241,9 @@ class DailyMixManager @Inject constructor( return stats.copy( playCount = stats.playCount.coerceAtLeast(0), totalPlayDurationMs = stats.totalPlayDurationMs.coerceAtLeast(0L), - lastPlayedTimestamp = stats.lastPlayedTimestamp.coerceAtLeast(0L) + lastPlayedTimestamp = stats.lastPlayedTimestamp.coerceAtLeast(0L), + skipCount = stats.skipCount.coerceAtLeast(0), + completedCount = stats.completedCount.coerceAtLeast(0) ) } @@ -257,6 +263,24 @@ class DailyMixManager @Inject constructor( ) } + suspend fun recordEngagement( + songId: String, + playInc: Int, + songDurationMs: Long = 0L, + timestamp: Long = System.currentTimeMillis(), + skipInc: Int = 0, + completedInc: Int = 0 + ) { + engagementDao.recordEngagement( + songId = songId, + playInc = playInc, + durationMs = songDurationMs.coerceAtLeast(0L), + timestamp = timestamp.coerceAtLeast(0L), + skipInc = skipInc, + completedInc = completedInc + ) + } + suspend fun incrementScore(songId: String) { recordPlay(songId) } @@ -270,7 +294,9 @@ class DailyMixManager @Inject constructor( SongEngagementStats( playCount = entity.playCount, totalPlayDurationMs = entity.totalPlayDurationMs, - lastPlayedTimestamp = entity.lastPlayedTimestamp + lastPlayedTimestamp = entity.lastPlayedTimestamp, + skipCount = entity.skipCount, + completedCount = entity.completedCount ) } } @@ -282,7 +308,7 @@ class DailyMixManager @Inject constructor( private suspend fun computeRankedSongs( allSongs: List, favoriteSongIds: Set, - random: java.util.Random + random: kotlin.random.Random ): List { if (allSongs.isEmpty()) return emptyList() @@ -367,7 +393,7 @@ class DailyMixManager @Inject constructor( val calendar = Calendar.getInstance() val seed = calendar.get(Calendar.YEAR) * 1000 + calendar.get(Calendar.DAY_OF_YEAR) - val random = java.util.Random(seed.toLong()) + val random = kotlin.random.Random(seed.toLong()) val rankedSongs = computeRankedSongs(allSongs, favoriteSongIds, random) if (rankedSongs.isEmpty()) { @@ -398,7 +424,7 @@ class DailyMixManager @Inject constructor( val calendar = Calendar.getInstance() val seed = calendar.get(Calendar.YEAR) * 1000 + calendar.get(Calendar.DAY_OF_YEAR) + 17 - val random = java.util.Random(seed.toLong()) + val random = kotlin.random.Random(seed.toLong()) val rankedSongs = computeRankedSongs(allSongs, favoriteSongIds, random) if (rankedSongs.isEmpty()) { @@ -466,7 +492,7 @@ class DailyMixManager @Inject constructor( // if called multiple times in one day, preserving prompt caching. val calendar = Calendar.getInstance() val seed = calendar.get(Calendar.YEAR) * 1000 + calendar.get(Calendar.DAY_OF_YEAR) + 42 - val random = java.util.Random(seed.toLong()) + val random = kotlin.random.Random(seed.toLong()) val rankedSongs = computeRankedSongs(allSongs, favoriteSongIds, random) if (rankedSongs.isEmpty()) { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiBehaviorDataCollector.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiBehaviorDataCollector.kt new file mode 100644 index 000000000..3718a97dc --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiBehaviorDataCollector.kt @@ -0,0 +1,284 @@ +package com.theveloper.pixelplay.data.ai + +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository +import com.theveloper.pixelplay.data.stats.StatsTimeRange +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AiBehaviorDataCollector @Inject constructor( + private val statsRepository: PlaybackStatsRepository +) { + data class BehaviorContext( + val totalPlays: Int = 0, + val totalListenTimeMs: Long = 0, + val skipCount: Int = 0, + val completionRate: Float = 0.85f, + val favoriteCount: Int = 0, + val topGenres: List = emptyList(), + val topArtists: List = emptyList(), + val recentlyPlayedSongs: List = emptyList(), + val peakListeningHours: List = emptyList(), + val averageSongDurationMs: Long = 0, + val preferredEnergyLevel: EnergyLevel = EnergyLevel.MEDIUM, + val listeningStreak: Int = 0, + val favoriteDecades: List = emptyList(), + val preferredLanguages: List = emptyList(), + val topPlayedSongIds: List = emptyList(), + val recentlyCompletedSongIds: List = emptyList(), + val frequentSkipPatterns: Map = emptyMap() + ) + + data class GenreStat(val genre: String, val playCount: Int, val skipRate: Float = 0f) + data class ArtistStat(val artist: String, val playCount: Int, val skipRate: Float = 0f) + data class SongPlayStat( + val songId: String, + val title: String, + val artist: String, + val playCount: Int, + val totalDurationMs: Long, + val lastPlayedMs: Long, + val estimatedSkips: Int = 0 + ) + + enum class EnergyLevel { LOW, MEDIUM, HIGH, VARIABLE } + enum class PlaySource { DAILY_MIX, AI_PLAYLIST, SEARCH, LIBRARY, RECOMMENDED, ALBUM, ARTIST, PLAYLIST, QUEUE, UNKNOWN } + enum class SkipReason { NOT_ENJOYING, SKIP_NEXT, PLAYBACK_ISSUE, WRONG_MOOD, TOO_FAMILIAR, EXPLICIT_FILTERED, UNKNOWN } + + private val songPlayCounts = mutableMapOf() + private val songSkipCounts = mutableMapOf() + private val songLastPlayed = mutableMapOf() + private val songCompletions = mutableMapOf() + + suspend fun gatherBehaviorContext(allSongs: List = emptyList()): BehaviorContext { + return try { + val summary = statsRepository.loadSummary(StatsTimeRange.ALL, allSongs) + val history = statsRepository.loadPlaybackHistory(50) + val events = statsRepository.exportEventsForBackup() + + val totalPlays = summary.totalPlayCount + val totalListenTime = summary.songs.sumOf { it.totalDurationMs } + val peakHours = summary.dayListeningDistribution?.buckets?.map { it.startMinute / 60 }?.distinct() ?: emptyList() + + val songMap = allSongs.associateBy { it.id } + + val allSongInfo = summary.songs.associate { s -> + val expectedDuration = s.totalDurationMs / maxOf(s.playCount, 1).toFloat() + val estimatedSkips = if (expectedDuration > 30_000f && s.playCount > 1) { + (s.playCount * 0.15f).toInt().coerceAtMost(s.playCount - 1) + } else 0 + s.songId to (s.playCount to estimatedSkips) + } + + val totalSkips = allSongInfo.values.sumOf { it.second } + val totalCompletions = totalPlays - totalSkips + val completionRate = if (totalPlays > 0) totalCompletions.toFloat() / totalPlays else 0.85f + + val topPlayedIds = summary.songs + .sortedByDescending { it.playCount } + .take(20) + .map { it.songId } + + val recentlyCompleted = history + .take(10) + .map { it.songId } + + val recentSongIds = history.map { it.songId }.distinct().take(30) + + val recentlyPlayed = recentSongIds.mapNotNull { id -> + val song = songMap[id] + val stats = summary.songs.find { it.songId == id } + val lastEvent = events.filter { it.songId == id }.maxByOrNull { it.timestamp } + if (song != null && stats != null) { + val info = allSongInfo[id] + SongPlayStat( + songId = id, + title = song.title, + artist = song.displayArtist, + playCount = stats.playCount, + totalDurationMs = stats.totalDurationMs, + lastPlayedMs = lastEvent?.timestamp ?: 0L, + estimatedSkips = info?.second ?: 0 + ) + } else null + } + + val skipPatterns = summary.songs + .filter { s -> + val expectedDuration = s.totalDurationMs / maxOf(s.playCount, 1).toFloat() + expectedDuration > 30_000f && s.playCount > 3 && + (s.totalDurationMs.toFloat() / maxOf(s.playCount, 1)) < (30_000f * s.playCount) + } + .associate { it.songId to (it.playCount * 0.15f).toInt().coerceAtMost(it.playCount - 1) } + + BehaviorContext( + totalPlays = totalPlays, + totalListenTimeMs = totalListenTime, + skipCount = totalSkips, + completionRate = completionRate, + favoriteCount = summary.songs.count { songMap[it.songId]?.isFavorite == true }, + topGenres = summary.topGenres.map { g -> + val genreSongs = summary.songs.filter { s -> songMap[s.songId]?.genre == g.genre } + val genreSkips = genreSongs.sumOf { allSongInfo[it.songId]?.second ?: 0 } + val genreRate = if (g.playCount > 0) genreSkips.toFloat() / g.playCount else 0f + GenreStat(g.genre, g.playCount, genreRate) + }, + topArtists = summary.topArtists.map { a -> + val artistSongs = summary.songs.filter { s -> s.artist == a.artist } + val artistSkips = artistSongs.sumOf { allSongInfo[it.songId]?.second ?: 0 } + val artistRate = if (a.playCount > 0) artistSkips.toFloat() / a.playCount else 0f + ArtistStat(a.artist, a.playCount, artistRate) + }, + recentlyPlayedSongs = recentlyPlayed, + peakListeningHours = peakHours, + averageSongDurationMs = if (summary.songs.isNotEmpty()) summary.songs.map { it.totalDurationMs }.average().toLong() else 0, + preferredEnergyLevel = inferEnergyLevel(summary), + listeningStreak = estimateListeningStreak(events), + favoriteDecades = estimateFavoriteDecades(summary, allSongs), + topPlayedSongIds = topPlayedIds, + recentlyCompletedSongIds = recentlyCompleted, + frequentSkipPatterns = skipPatterns + ) + } catch (e: Exception) { + Timber.tag("AIBehavior").e(e, "Failed to gather behavior context, using defaults") + BehaviorContext() + } + } + + suspend fun recordPlayEvent(song: Song, playDurationMs: Long, completed: Boolean, source: PlaySource) { + songPlayCounts[song.id] = (songPlayCounts[song.id] ?: 0) + 1 + songLastPlayed[song.id] = System.currentTimeMillis() + if (completed) songCompletions[song.id] = (songCompletions[song.id] ?: 0) + 1 + Timber.tag("AIBehavior").d("Play: ${song.title}, dur=${playDurationMs}ms, compl=$completed, src=$source") + } + + suspend fun recordSkipEvent(song: Song, reason: SkipReason) { + songSkipCounts[song.id] = (songSkipCounts[song.id] ?: 0) + 1 + Timber.tag("AIBehavior").d("Skip: ${song.title}, reason=$reason") + } + + suspend fun recordFavoriteEvent(song: Song, isFavorite: Boolean) { + Timber.tag("AIBehavior").d("Favorite: ${song.title}, isFavorite=$isFavorite") + } + + suspend fun getPerSongStats(songId: String): SongStats { + return SongStats( + playCount = (songPlayCounts[songId] ?: 0), + skipCount = (songSkipCounts[songId] ?: 0), + lastPlayedMs = (songLastPlayed[songId] ?: 0L), + completionCount = (songCompletions[songId] ?: 0) + ) + } + + suspend fun getPerSongStatsFromSummary(songId: String, allSongs: List): SongStats { + val summary = statsRepository.loadSummary(StatsTimeRange.ALL, allSongs) + val songPlay = summary.songs.find { it.songId == songId } + return SongStats( + playCount = songPlay?.playCount ?: 0, + skipCount = 0, + lastPlayedMs = 0L, + completionCount = songPlay?.playCount ?: 0 + ) + } + + data class SongStats( + val playCount: Int = 0, + val skipCount: Int = 0, + val lastPlayedMs: Long = 0, + val completionCount: Int = 0 + ) + + suspend fun generateBehaviorSummary(allSongs: List = emptyList()): String { + val ctx = gatherBehaviorContext(allSongs) + val totalActions = ctx.totalPlays + ctx.skipCount + val skipRate = if (totalActions > 0) ((ctx.skipCount.toFloat() / totalActions) * 100).toInt() else 0 + + return buildString { + append("Listened to ${ctx.totalPlays} songs ") + append("for ${formatDuration(ctx.totalListenTimeMs)}. ") + append("Skip rate: ${skipRate}%. ") + if (ctx.topGenres.isNotEmpty()) { + append("Top genres: ${ctx.topGenres.take(3).joinToString(", ") { it.genre }}. ") + } + if (ctx.topArtists.isNotEmpty()) { + append("Favorite artists: ${ctx.topArtists.take(3).joinToString(", ") { it.artist }}. ") + } + if (ctx.recentlyPlayedSongs.isNotEmpty()) { + val lastTimestamp = ctx.recentlyPlayedSongs.first().lastPlayedMs + append("Last played: ${formatTimestamp(lastTimestamp)}. ") + } + append("Energy preference: ${ctx.preferredEnergyLevel.name.lowercase()}. ") + if (ctx.listeningStreak > 0) append("Current streak: ${ctx.listeningStreak} days.") + } + } + + suspend fun getUserContext(allSongs: List = emptyList()): String { + val ctx = gatherBehaviorContext(allSongs) + return buildString { + append("User has listened to ${ctx.totalPlays} songs total. ") + append("Favorite genres: ${ctx.topGenres.take(3).joinToString { "${it.genre} (${it.playCount} plays)" }}. ") + if (ctx.peakListeningHours.isNotEmpty()) append("Peak listening hours: ${ctx.peakListeningHours.joinToString()}. ") + append("Avg song completion: ${(ctx.completionRate * 100).toInt()}%. ") + if (ctx.frequentSkipPatterns.isNotEmpty()) { + append("Tends to skip: ${ctx.frequentSkipPatterns.size} songs frequently. ") + } + } + } + + private fun inferEnergyLevel(summary: PlaybackStatsRepository.PlaybackStatsSummary): EnergyLevel { + val g = summary.topGenres.take(3).map { it.genre.lowercase() } + val high = listOf("rock", "metal", "punk", "electronic", "dance", "edm", "hip-hop", "rap", "drill", "trap") + val low = listOf("ambient", "classical", "jazz", "acoustic", "lo-fi", "chill", "folk") + val hc = g.count { gen -> high.any { it in gen } } + val lc = g.count { gen -> low.any { it in gen } } + return if (hc > lc) EnergyLevel.HIGH else if (lc > hc) EnergyLevel.LOW else EnergyLevel.MEDIUM + } + + private fun estimateListeningStreak(events: List): Int { + if (events.size < 2) return 0 + val sortedTimestamps = events.map { it.timestamp }.sortedDescending() + var streak = 1 + val dayMs = 86400000L + for (i in 0 until sortedTimestamps.size - 1) { + if (sortedTimestamps[i] - sortedTimestamps[i + 1] <= dayMs * 2) streak++ + else break + } + return streak + } + + private fun estimateFavoriteDecades( + summary: PlaybackStatsRepository.PlaybackStatsSummary, + allSongs: List + ): List { + val songMap = allSongs.associateBy { it.id } + val decadeCounts = mutableMapOf() + summary.songs.forEach { s -> + val year = songMap[s.songId]?.year + if (year != null && year > 0) { + val decade = "${(year / 10) * 10}s" + decadeCounts[decade] = (decadeCounts[decade] ?: 0) + s.playCount + } + } + return decadeCounts.entries.sortedByDescending { it.value }.take(3).map { it.key } + } + + private fun formatDuration(ms: Long): String { + val hours = ms / (1000 * 60 * 60) + val minutes = (ms % (1000 * 60 * 60)) / (1000 * 60) + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" + } + + private fun formatTimestamp(epochMs: Long): String { + val cal = java.util.Calendar.getInstance().apply { timeInMillis = epochMs } + val now = java.util.Calendar.getInstance() + return when { + cal.get(java.util.Calendar.DAY_OF_YEAR) == now.get(java.util.Calendar.DAY_OF_YEAR) && + cal.get(java.util.Calendar.YEAR) == now.get(java.util.Calendar.YEAR) -> "today" + cal.get(java.util.Calendar.DAY_OF_YEAR) == now.get(java.util.Calendar.DAY_OF_YEAR) - 1 && + cal.get(java.util.Calendar.YEAR) == now.get(java.util.Calendar.YEAR) -> "yesterday" + else -> "${cal.get(java.util.Calendar.DAY_OF_MONTH)}/${cal.get(java.util.Calendar.MONTH) + 1}" + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiCacheManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiCacheManager.kt new file mode 100644 index 000000000..c4f92828a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiCacheManager.kt @@ -0,0 +1,201 @@ +package com.theveloper.pixelplay.data.ai + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import timber.log.Timber +import java.io.File +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton + +/** + * AI response caching manager for faster repeated queries. + * Caches AI responses based on prompt hash. + */ +@Singleton +class AiCacheManager @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val PREFS_NAME = "ai_cache_settings" + private const val KEY_CACHING_ENABLED = "ai_caching_enabled" + private const val KEY_DEBUG_MODE = "debug_mode_enabled" + private const val CACHE_DIR = "ai_cache" + private const val MAX_CACHE_SIZE_MB = 50 + private const val CACHE_EXPIRY_DAYS = 7 + } + + private val settingsPrefs: SharedPreferences by lazy { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private val cacheDir: File + get() = File(context.filesDir, CACHE_DIR).also { it.mkdirs() } + + /** + * Gets a cached response for a given prompt hash. + */ + suspend fun getCachedResponse(promptHash: String): String? { + if (!isCachingEnabled()) return null + + val cacheFile = getCacheFile(promptHash) + if (!cacheFile.exists()) return null + + // Check if cache is expired + val ageDays = (System.currentTimeMillis() - cacheFile.lastModified()) / (1000 * 60 * 60 * 24) + if (ageDays > CACHE_EXPIRY_DAYS) { + cacheFile.delete() + Timber.tag("AiCache").d("Cache expired for: $promptHash") + return null + } + + return try { + cacheFile.readText() + } catch (e: Exception) { + Timber.tag("AiCache").e(e, "Failed to read cache for: $promptHash") + null + } + } + + /** + * Saves a response to cache. + */ + suspend fun cacheResponse(promptHash: String, response: String) { + if (!isCachingEnabled()) return + + try { + val cacheFile = getCacheFile(promptHash) + cacheFile.writeText(response) + Timber.tag("AiCache").d("Cached response for: $promptHash") + + // Clean up if needed + cleanupOldCache() + } catch (e: Exception) { + Timber.tag("AiCache").e(e, "Failed to cache response for: $promptHash") + } + } + + /** + * Invalidates cache for specific prompts. + */ + fun invalidateCache(promptHash: String) { + getCacheFile(promptHash).delete() + } + + /** + * Clears all cached responses. + */ + fun clearCache() { + cacheDir.listFiles()?.forEach { it.delete() } + } + + /** + * Gets current cache size in MB. + */ + fun getCacheSizeMb(): Double { + val size = cacheDir.listFiles()?.sumOf { it.length() } ?: 0 + return size.toDouble() / (1024 * 1024) + } + + /** + * Generates a hash for a prompt to use as cache key. + */ + fun generatePromptHash(prompt: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(prompt.toByteArray()) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + /** + * Generates a hash including context variables. + */ + fun generateContextAwareHash( + prompt: String, + provider: String, + model: String, + temperature: Float + ): String { + val combined = "$prompt|$provider|$model|$temperature" + return generatePromptHash(combined) + } + + /** + * Enables or disables AI caching. + */ + fun setCachingEnabled(enabled: Boolean) { + settingsPrefs.edit().putBoolean(KEY_CACHING_ENABLED, enabled).apply() + Timber.tag("AiCache").d("Caching enabled: $enabled") + } + + /** + * Gets debug mode state from local SharedPreferences. + * This is used as a proxy for AI features enabled status. + */ + private suspend fun isDebugModeEnabled(): Boolean { + return try { + // Use local SharedPreferences for debug mode + // This acts as a proxy for AI features being enabled + settingsPrefs.getBoolean(KEY_DEBUG_MODE, true) // Default to true to enable caching + } catch (e: Exception) { + Timber.tag("AiCache").e(e, "Failed to check debug mode") + false + } + } + + private suspend fun isCachingEnabled(): Boolean { + return try { + // First check if caching is explicitly enabled in local settings + val cachingEnabled = settingsPrefs.getBoolean(KEY_CACHING_ENABLED, true) + if (!cachingEnabled) return false + + // Then check debug mode (or use as proxy for AI features) + isDebugModeEnabled() + } catch (e: Exception) { + Timber.tag("AiCache").e(e, "Failed to check if caching enabled") + false + } + } + + private fun getCacheFile(promptHash: String): File { + return File(cacheDir, "cache_$promptHash.txt") + } + + private fun cleanupOldCache() { + val currentSizeMb = getCacheSizeMb() + if (currentSizeMb > MAX_CACHE_SIZE_MB) { + // Delete oldest files until under limit + cacheDir.listFiles() + ?.sortedBy { it.lastModified() } + ?.forEach { file -> + if (getCacheSizeMb() <= MAX_CACHE_SIZE_MB * 0.8) return + file.delete() + } + } + } + + /** + * Gets cache statistics. + */ + fun getCacheStats(): CacheStats { + val files = cacheDir.listFiles() ?: emptyArray() + val totalSize = files.sumOf { it.length() } + val oldestTimestamp = files.minOfOrNull { it.lastModified() } ?: System.currentTimeMillis() + val newestTimestamp = files.maxOfOrNull { it.lastModified() } ?: System.currentTimeMillis() + + return CacheStats( + entryCount = files.size, + totalSizeBytes = totalSize, + oldestEntryAgeDays = ((System.currentTimeMillis() - oldestTimestamp) / (1000 * 60 * 60 * 24)).toInt(), + newestEntryAgeDays = ((System.currentTimeMillis() - newestTimestamp) / (1000 * 60 * 60 * 24)).toInt() + ) + } + + data class CacheStats( + val entryCount: Int, + val totalSizeBytes: Long, + val oldestEntryAgeDays: Int, + val newestEntryAgeDays: Int + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiDeviceCapabilities.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiDeviceCapabilities.kt new file mode 100644 index 000000000..8d0bad032 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiDeviceCapabilities.kt @@ -0,0 +1,174 @@ +package com.theveloper.pixelplay.data.ai + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Hardware survey for AI capabilities. + * Determines what AI models and features the device can support. + */ +@Singleton +class AiDeviceCapabilities @Inject constructor( + @ApplicationContext private val context: Context +) { + data class DeviceCapabilities( + val totalRamMb: Long, + val availableRamMb: Long, + val cpuCores: Int, + val cpuArchitecture: String, + val is64Bit: Boolean, + val supportsTflite: Boolean, + val supportsGpuInference: Boolean, + val supportsNnapi: Boolean, + val gpuRenderer: String?, + val recommendedModelSizeMb: Int, + val supportsStreaming: Boolean, + val recommendedProviders: List, + val osVersion: Int, + val sdkVersion: Int, + val deviceModel: String, + val manufacturer: String + ) + + fun getCapabilities(): DeviceCapabilities { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + + val totalRamMb = memInfo.totalMem / (1024 * 1024) + val availableRamMb = memInfo.availMem / (1024 * 1024) + + val cpuCores = Runtime.getRuntime().availableProcessors() + val cpuArch = System.getProperty("os.arch") ?: "unknown" + val is64Bit = Build.SUPPORTED_64_BIT_ABIS.isNotEmpty() + + val recommendedSizeMb = when { + totalRamMb >= 6144 -> 1000 + totalRamMb >= 3072 -> 500 + totalRamMb >= 2048 -> 256 + totalRamMb >= 1536 -> 150 + totalRamMb >= 1024 -> 100 + else -> 50 + } + + val supportsTfliteRuntime = isTfliteRuntimeAvailable() + val supportsGpu = supportsTfliteRuntime && checkGpuSupport() + val supportsNnapiRuntime = supportsTfliteRuntime && checkNnapiSupport() + + val recommendedProviders = buildList { + if (supportsTfliteRuntime) { + add("LOCAL") + } + add("OLLAMA") + if (hasNetwork()) { + add("GEMINI") + add("OPENAI") + add("ANTHROPIC") + } + } + + return DeviceCapabilities( + totalRamMb = totalRamMb, + availableRamMb = availableRamMb, + cpuCores = cpuCores, + cpuArchitecture = cpuArch, + is64Bit = is64Bit, + supportsTflite = supportsTfliteRuntime, + supportsGpuInference = supportsGpu, + supportsNnapi = supportsNnapiRuntime, + gpuRenderer = getGpuRenderer(), + recommendedModelSizeMb = recommendedSizeMb, + supportsStreaming = availableRamMb > 512, + recommendedProviders = recommendedProviders, + osVersion = Build.VERSION.SDK_INT, + sdkVersion = Build.VERSION.SDK_INT, + deviceModel = Build.MODEL, + manufacturer = Build.MANUFACTURER + ) + } + + fun canRunModel(modelSizeMb: Int): Boolean { + val caps = getCapabilities() + // Require 2x model size in available RAM + return caps.availableRamMb >= (modelSizeMb * 2) + } + + fun getRecommendedModelTypes(): List { + val caps = getCapabilities() + return buildList { + // Everyone can do basic recommendations + add("GENRE_CLASSIFICATION") + if (caps.availableRamMb >= 256) { + add("SENTIMENT") + } + if (caps.availableRamMb >= 512) { + add("EMBEDDING") + } + if (caps.availableRamMb >= 1536 && caps.cpuCores >= 4) { + add("GENERAL_CHAT") + } + } + } + + private fun hasNetwork(): Boolean { + return try { + val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as android.net.ConnectivityManager + connectivity.activeNetwork != null + } catch (e: Exception) { + false + } + } + + private fun checkGpuSupport(): Boolean { + return try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + activityManager.isLowRamDevice == false + } catch (e: Exception) { + false + } + } + + private fun checkNnapiSupport(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + private fun getGpuRenderer(): String? { + return try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val config = activityManager.deviceConfigurationInfo + config?.glEsVersion + } catch (e: Exception) { + null + } + } + + private fun isTfliteRuntimeAvailable(): Boolean { + return try { + Class.forName("org.tensorflow.lite.Interpreter") + true + } catch (_: ClassNotFoundException) { + false + } catch (_: Exception) { + false + } + } + + fun getSummary(): String { + val caps = getCapabilities() + return buildString { + append("Device: ${caps.manufacturer} ${caps.deviceModel}\n") + append("RAM: ${caps.totalRamMb}MB total, ${caps.availableRamMb}MB available\n") + append("CPU: ${caps.cpuCores} cores, ${caps.cpuArchitecture}\n") + append("64-bit: ${caps.is64Bit}\n") + append("GPU: ${caps.gpuRenderer ?: "unknown"}\n") + append("NNAPI: ${caps.supportsNnapi}\n") + append("Recommended model size: ${caps.recommendedModelSizeMb}MB\n") + append("Recommended providers: ${caps.recommendedProviders.joinToString(", ")}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiErrorHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiErrorHandler.kt new file mode 100644 index 000000000..2919b684b --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiErrorHandler.kt @@ -0,0 +1,242 @@ +package com.theveloper.pixelplay.data.ai + +import android.content.Context +import com.theveloper.pixelplay.data.ai.provider.AiProvider +import com.theveloper.pixelplay.data.ai.provider.AiProviderException +import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * AI error handler with fallbacks and error recovery. + * Provides graceful degradation when AI services fail. + */ +@Singleton +class AiErrorHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val aiLogger: AiLogger, + private val preferencesRepo: AiPreferencesRepository +) { + /** + * Error categories for AI operations. + */ + enum class ErrorCategory { + NETWORK_ERROR, + API_KEY_ERROR, + RATE_LIMIT_ERROR, + MODEL_UNAVAILABLE_ERROR, + TIMEOUT_ERROR, + PARSING_ERROR, + EMPTY_RESPONSE_ERROR, + UNKNOWN_ERROR + } + + /** + * Result of error analysis with recovery suggestions. + */ + data class ErrorAnalysis( + val category: ErrorCategory, + val message: String, + val userMessage: String, + val canRetry: Boolean, + val suggestedFallback: AiProvider?, + val recoveryAction: RecoveryAction + ) + + enum class RecoveryAction { + RETRY_SAME_PROVIDER, + SWITCH_PROVIDER, + USE_FALLBACK_RESPONSE, + DISABLE_AI_FEATURES, + SHOW_ERROR + } + + /** + * Analyzes an error and returns recovery suggestions. + */ + suspend fun analyzeError(error: Throwable, currentProvider: AiProvider): ErrorAnalysis { + val cause = findRootCause(error) + val message = cause.message ?: "Unknown error" + val category = categorizeError(message, error) + val userMessage = getUserMessage(category, message) + val canRetry = canRetryOperation(category) + val fallback = getFallbackProvider(currentProvider, category) + val recoveryAction = determineRecoveryAction(category, canRetry, fallback) + + aiLogger.log("op" to "ERROR_ANALYSIS", "provider" to currentProvider.name, "error" to message) + + return ErrorAnalysis( + category = category, + message = message, + userMessage = userMessage, + canRetry = canRetry, + suggestedFallback = fallback, + recoveryAction = recoveryAction + ) + } + + /** + * Attempts to recover from an error. + */ + suspend fun withRecovery( + error: Throwable, + currentProvider: AiProvider, + operation: suspend (AiProvider) -> Result, + maxRetries: Int = 2 + ): Result { + var lastError = error + var currentProviderUsed = currentProvider + + repeat(maxRetries + 1) { attempt -> + if (attempt > 0) { + Timber.tag("AiErrorHandler").d("Retry attempt $attempt for ${currentProviderUsed.name}") + } + + val result = operation(currentProviderUsed) + + if (result.isSuccess) { + return result + } + + lastError = result.exceptionOrNull() ?: Exception("Unknown error") + val analysis = analyzeError(lastError, currentProviderUsed) + + when (analysis.recoveryAction) { + RecoveryAction.RETRY_SAME_PROVIDER -> { + // Already retrying, continue to next attempt + } + RecoveryAction.SWITCH_PROVIDER -> { + analysis.suggestedFallback?.let { fallback -> + currentProviderUsed = fallback + Timber.tag("AiErrorHandler").d("Switching to fallback provider: ${fallback.name}") + } + } + RecoveryAction.USE_FALLBACK_RESPONSE -> { + return Result.failure(Exception("Using fallback response")) + } + else -> { + return result + } + } + } + + return Result.failure(lastError) + } + + private fun findRootCause(error: Throwable): Throwable { + return generateSequence(error) { it.cause } + .lastOrNull() ?: error + } + + private fun categorizeError(message: String, error: Throwable): ErrorCategory { + val m = message.lowercase() + if (error is AiProviderException) return when { + error.isApiKeyIssue() || "api key" in m -> ErrorCategory.API_KEY_ERROR + error.isBillingIssue() || "quota" in m -> ErrorCategory.RATE_LIMIT_ERROR + error.isModelUnavailable() -> ErrorCategory.MODEL_UNAVAILABLE_ERROR + else -> ErrorCategory.UNKNOWN_ERROR + } + return when { + "network" in m || "connect" in m || "no internet" in m -> ErrorCategory.NETWORK_ERROR + "api key" in m || "unauthorized" in m || "401" in m || "403" in m -> ErrorCategory.API_KEY_ERROR + "rate limit" in m || "429" in m || "too many requests" in m -> ErrorCategory.RATE_LIMIT_ERROR + "model" in m && ("not found" in m || "unavailable" in m) -> ErrorCategory.MODEL_UNAVAILABLE_ERROR + "timeout" in m -> ErrorCategory.TIMEOUT_ERROR + "parse" in m || "json" in m || "format" in m -> ErrorCategory.PARSING_ERROR + "empty" in m || "no response" in m -> ErrorCategory.EMPTY_RESPONSE_ERROR + else -> ErrorCategory.UNKNOWN_ERROR + } + } + + private fun getUserMessage(category: ErrorCategory, message: String): String = when (category) { + ErrorCategory.NETWORK_ERROR -> "No internet connection. Check WiFi or mobile data." + ErrorCategory.API_KEY_ERROR -> "API key issue. Check AI provider settings." + ErrorCategory.RATE_LIMIT_ERROR -> "Rate limit reached. Wait and try again." + ErrorCategory.MODEL_UNAVAILABLE_ERROR -> "AI model unavailable. Try a different model." + ErrorCategory.TIMEOUT_ERROR -> "Request timed out. The AI service is slow." + ErrorCategory.PARSING_ERROR -> "Failed to parse AI response. Try again." + ErrorCategory.EMPTY_RESPONSE_ERROR -> "AI returned an empty response. Try again." + ErrorCategory.UNKNOWN_ERROR -> "Error: ${message.take(100)}" + } + + private fun canRetryOperation(category: ErrorCategory): Boolean { + return when (category) { + ErrorCategory.NETWORK_ERROR -> true + ErrorCategory.TIMEOUT_ERROR -> true + ErrorCategory.RATE_LIMIT_ERROR -> true + ErrorCategory.PARSING_ERROR -> true + ErrorCategory.EMPTY_RESPONSE_ERROR -> true + else -> false + } + } + + private suspend fun getFallbackProvider(currentProvider: AiProvider, category: ErrorCategory): AiProvider? { + // Priority order for fallback: prefer providers with configured API keys + val priorityProviders = listOf( + AiProvider.GEMINI, + AiProvider.OPENAI, + AiProvider.ANTHROPIC, + AiProvider.OLLAMA + ) + + // Find first provider that has API key configured and isn't the current one + for (provider in priorityProviders) { + if (provider == currentProvider) continue + + // Skip Ollama in fallback - it's for model downloads only + if (provider == AiProvider.OLLAMA) continue + + // Check if this provider has an API key configured + val apiKey = try { + preferencesRepo.getApiKey(provider).first() + } catch (e: Exception) { + Timber.w(e, "Failed to check API key for $provider") + continue + } + + if (apiKey.isNotBlank()) { + return provider + } + } + + return null // No fallback available + } + + private fun determineRecoveryAction( + category: ErrorCategory, + canRetry: Boolean, + fallback: AiProvider? + ): RecoveryAction { + if (canRetry) { + return RecoveryAction.RETRY_SAME_PROVIDER + } + + return when (category) { + ErrorCategory.API_KEY_ERROR, + ErrorCategory.MODEL_UNAVAILABLE_ERROR -> { + if (fallback != null) RecoveryAction.SWITCH_PROVIDER + else RecoveryAction.SHOW_ERROR + } + ErrorCategory.NETWORK_ERROR -> { + if (fallback != null) RecoveryAction.SWITCH_PROVIDER + else RecoveryAction.SHOW_ERROR + } + ErrorCategory.RATE_LIMIT_ERROR -> { + if (fallback != null) RecoveryAction.SWITCH_PROVIDER + else RecoveryAction.SHOW_ERROR + } + else -> RecoveryAction.SHOW_ERROR + } + } + + /** + * Gets a user-friendly error message. + */ + suspend fun getUserFriendlyMessage(error: Throwable): String { + val analysis = analyzeError(error, AiProvider.GEMINI) + return analysis.userMessage + } +} \ No newline at end of file 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 78% 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..8953583b5 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 @@ -8,6 +8,8 @@ import com.theveloper.pixelplay.data.database.AiCacheEntity import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository import com.theveloper.pixelplay.data.database.AiUsageDao import com.theveloper.pixelplay.data.database.AiUsageEntity +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.worker.AiWorkerManager import com.theveloper.pixelplay.di.AppScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first @@ -17,14 +19,24 @@ import timber.log.Timber import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton +import kotlinx.serialization.Serializable + +@Serializable +data class AiModel( + val name: String, + val displayName: String +) @Singleton -class AiOrchestrator @Inject constructor( +class AiHandler @Inject constructor( private val preferencesRepo: AiPreferencesRepository, private val clientFactory: AiClientFactory, private val cacheDao: AiCacheDao, private val usageDao: AiUsageDao, private val promptEngine: AiSystemPromptEngine, + private val musicRepository: MusicRepository, + private val digestGenerator: UserProfileDigestGenerator, + private val workerManager: AiWorkerManager, @AppScope private val appScope: CoroutineScope ) { // Cooldown timer: Provider -> Expiry Timestamp @@ -37,6 +49,12 @@ class AiOrchestrator @Inject constructor( // Request timeout: 60 seconds max per provider attempt private val REQUEST_TIMEOUT_MS = 60_000L + // Clean up expired cooldowns periodically to prevent memory growth + private fun cleanupCooldowns() { + val now = System.currentTimeMillis() + providerCooldowns.entries.removeIf { (_, expiry) -> now > expiry } + } + private fun String.sha256(): String { return MessageDigest.getInstance("SHA-256") .digest(this.toByteArray()) @@ -60,6 +78,58 @@ class AiOrchestrator @Inject constructor( preferencesRepo.setModel(provider, model) } + suspend fun fetchAvailableModels(provider: AiProvider, apiKey: String): Result> { + return try { + if (apiKey.isBlank() && provider.requiresApiKey) { + return Result.failure(Exception("API Key is required for ${provider.displayName}")) + } + val client = clientFactory.createClient(provider, apiKey) + val models = client.getAvailableModels(apiKey).map { modelName -> + AiModel(modelName, formatDisplayName(modelName)) + } + Result.success(models) + } catch (e: Exception) { + Timber.tag("AiHandler").e(e, "Error fetching models for ${provider.displayName}") + Result.failure(e) + } + } + + private fun formatDisplayName(modelName: String): String { + return modelName + .split("-", "_", "/") + .filter { it.isNotBlank() } + .joinToString(" ") { word -> + word.replaceFirstChar { it.uppercase() } + } + } + + suspend fun performAiTask( + prompt: String, + type: AiSystemPromptType, + runInBackground: Boolean = false, + temperature: Float = 0.7f + ): String? { + if (runInBackground) { + workerManager.enqueueAiTask(prompt, type, temperature) + return null + } else { + val allSongs = musicRepository.getAllSongsOnce() + val context = if (type == AiSystemPromptType.PLAYLIST || + type == AiSystemPromptType.TAGGING || + type == AiSystemPromptType.PERSONA) { + val maxContext = preferencesRepo.getMaxSongsForContextOnce() + digestGenerator.generateDigest(allSongs, true, maxContext) + } else "" + + return generateContent( + prompt = prompt, + type = type, + temperature = temperature, + context = context + ) + } + } + private suspend fun generateWithRecovery( provider: AiProvider, apiKey: String, @@ -144,8 +214,6 @@ class AiOrchestrator @Inject constructor( // 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 @@ -191,7 +259,7 @@ class AiOrchestrator @Inject constructor( try { val apiKey = getApiKey(provider) - if (apiKey.isBlank()) { + if (apiKey.isBlank() && provider.requiresApiKey) { failedProviders.add("${provider.name}: no API key configured") continue } @@ -235,7 +303,7 @@ class AiOrchestrator @Inject constructor( ) ) }.onFailure { error -> - Timber.tag("AiOrchestrator").e(error, "Failed to persist AI usage") + Timber.tag("AiHandler").e(error, "Failed to persist AI usage") } } @@ -244,10 +312,11 @@ class AiOrchestrator @Inject constructor( } catch (e: Exception) { // AI Optimization: Robust failover logic—if one provider fails, we log and try the next in the chain val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(provider.displayName, e) - Timber.tag("AiOrchestrator").w(e, "Provider ${provider.name} failed: ${failure.message}") + Timber.tag("AiHandler").w(e, "Provider ${provider.name} failed: ${failure.message}") failedProviders.add("${provider.name}: ${failure.message ?: "Unknown error"}") // Trigger cooldown only on provider-level outages and account problems. if (failure.shouldCooldown()) { + cleanupCooldowns() // Clean up old entries before adding new one providerCooldowns[provider] = now + COOLDOWN_DURATION_MS } } @@ -268,7 +337,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/AiLogger.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiLogger.kt new file mode 100644 index 000000000..5a83e28ee --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiLogger.kt @@ -0,0 +1,153 @@ +package com.theveloper.pixelplay.data.ai + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.io.FileWriter +import java.io.PrintWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Enhanced AI logging system for debugging and analytics. + * Stores detailed logs of AI operations, prompts, and responses. + */ +@Singleton +class AiLogger @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val LOG_DIR = "ai_logs" + private const val LOG_FILE = "ai_operations.log" + private const val MAX_LOG_SIZE_MB = 10 + private const val MAX_LOG_FILES = 5 + private const val PREFS_NAME = "ai_logger_settings" + private const val KEY_DEBUG_MODE = "ai_debug_mode" + } + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val logDir: File + get() = File(context.filesDir, LOG_DIR).also { it.mkdirs() } + + private val currentLogFile: File + get() = File(logDir, LOG_FILE) + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + + // Cache debug mode to avoid calling suspend function repeatedly + private var cachedDebugMode: Boolean? = null + private var lastCacheTime = 0L + private val cacheValidDuration = 5000L // 5 seconds + + fun log(vararg fields: Pair) { + if (!shouldLogSync()) return + val line = buildString { + append("[${dateFormat.format(Date())}]") + fields.forEach { (k, v) -> if (v != null) append(" | $k=$v") } + append("\n") + } + writeToLog(line) + } + + /** + * Gets recent log entries for display in settings. + */ + fun getRecentLogs(lineCount: Int = 50): List { + return try { + if (!currentLogFile.exists()) return emptyList() + currentLogFile.readLines().takeLast(lineCount) + } catch (e: Exception) { + emptyList() + } + } + + /** + * Gets log file size in MB. + */ + fun getLogSizeMb(): Double { + return if (currentLogFile.exists()) { + currentLogFile.length().toDouble() / (1024 * 1024) + } else 0.0 + } + + /** + * Clears all AI logs. + */ + fun clearLogs() { + logDir.listFiles()?.forEach { it.delete() } + } + + /** + * Exports logs to a shareable file. + */ + fun exportLogs(): File? { + return try { + val exportFile = File(logDir, "ai_logs_export_${System.currentTimeMillis()}.txt") + val logs = getRecentLogs(500) + exportFile.writeText(logs.joinToString("\n")) + exportFile + } catch (e: Exception) { + null + } + } + + /** + * Synchronous version of shouldLog that caches the result. + * This avoids calling suspend functions from non-suspend contexts. + */ + private fun shouldLogSync(): Boolean { + // Check cache + val now = System.currentTimeMillis() + if (cachedDebugMode != null && (now - lastCacheTime) < cacheValidDuration) { + return cachedDebugMode == true + } + + // Refresh cache using local SharedPreferences + cachedDebugMode = prefs.getBoolean(KEY_DEBUG_MODE, false) + lastCacheTime = now + + return cachedDebugMode == true + } + + /** + * Asynchronous version for coroutine contexts. + */ + private suspend fun shouldLog(): Boolean { + return prefs.getBoolean(KEY_DEBUG_MODE, false) + } + + private fun writeToLog(line: String) { + try { + rotateLogsIfNeeded() + + FileWriter(currentLogFile, true).use { fw -> + PrintWriter(fw).use { writer -> + writer.write(line) + } + } + } catch (e: Exception) { + // Silently fail - logging should never crash the app + } + } + + private fun rotateLogsIfNeeded() { + if (currentLogFile.length() > MAX_LOG_SIZE_MB * 1024 * 1024) { + // Archive current log + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val archived = File(logDir, "ai_operations_$timestamp.log") + currentLogFile.renameTo(archived) + + // Delete old archives + logDir.listFiles() + ?.filter { it.name.startsWith("ai_operations_") && it.name.endsWith(".log") } + ?.sortedByDescending { it.lastModified() } + ?.drop(MAX_LOG_FILES) + ?.forEach { it.delete() } + } + } +} \ No newline at end of file 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/AiNotificationManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiNotificationManager.kt index a7a33ffb6..ebaf0e30b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiNotificationManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiNotificationManager.kt @@ -1,11 +1,11 @@ package com.theveloper.pixelplay.data.ai - import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.theveloper.pixelplay.R import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -18,53 +18,110 @@ class AiNotificationManager @Inject constructor( private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager companion object { - const val CHANNEL_ID = "ai_generation_channel" + const val CHANNEL_PROGRESS = "ai_progress_channel" + const val CHANNEL_COMPLETION = "ai_completion_channel" + const val CHANNEL_ERROR = "ai_error_channel" + const val CHANNEL_INFO = "ai_info_channel" const val PROGRESS_NOTIFICATION_ID = 1001 const val COMPLETION_NOTIFICATION_ID = 1002 + const val ERROR_NOTIFICATION_ID = 1003 + const val INFO_NOTIFICATION_ID = 1004 } init { - createChannel() + createChannels() } - private fun createChannel() { + private fun createChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = "AI Generation" - val descriptionText = "Notifications for AI processing and generation" - val importance = NotificationManager.IMPORTANCE_LOW - val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { - description = descriptionText - } - notificationManager.createNotificationChannel(channel) + val progressChannel = NotificationChannel( + CHANNEL_PROGRESS, "AI Generation Progress", + NotificationManager.IMPORTANCE_LOW + ).apply { description = "Shows ongoing AI task progress" } + notificationManager.createNotificationChannel(progressChannel) + + val completionChannel = NotificationChannel( + CHANNEL_COMPLETION, "AI Task Completed", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { description = "Notifies when AI tasks finish successfully" } + notificationManager.createNotificationChannel(completionChannel) + + val errorChannel = NotificationChannel( + CHANNEL_ERROR, "AI Errors", + NotificationManager.IMPORTANCE_HIGH + ).apply { description = "Alerts for AI task failures" } + notificationManager.createNotificationChannel(errorChannel) + + val infoChannel = NotificationChannel( + CHANNEL_INFO, "AI Information", + NotificationManager.IMPORTANCE_MIN + ).apply { description = "Informational AI messages" } + notificationManager.createNotificationChannel(infoChannel) } } - fun showProgress(title: String, message: String, progress: Int, max: Int = 100) { - val builder = NotificationCompat.Builder(context, CHANNEL_ID) + fun showProgress(title: String, message: String, progress: Int, max: Int = 100, showIndeterminate: Boolean = false) { + val builder = NotificationCompat.Builder(context, CHANNEL_PROGRESS) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle(title) .setContentText(message) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) - .setProgress(max, progress, progress == 0) + .setProgress(max, progress, showIndeterminate) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) notificationManager.notify(PROGRESS_NOTIFICATION_ID, builder.build()) } fun showCompletion(title: String, message: String) { notificationManager.cancel(PROGRESS_NOTIFICATION_ID) - - val builder = NotificationCompat.Builder(context, CHANNEL_ID) + + val builder = NotificationCompat.Builder(context, CHANNEL_COMPLETION) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setContentTitle(title) .setContentText(message) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build()) } + fun showError(title: String, message: String) { + notificationManager.cancel(PROGRESS_NOTIFICATION_ID) + + val builder = NotificationCompat.Builder(context, CHANNEL_ERROR) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + + notificationManager.notify(ERROR_NOTIFICATION_ID, builder.build()) + } + + fun showInfo(title: String, message: String) { + val builder = NotificationCompat.Builder(context, CHANNEL_INFO) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + + notificationManager.notify(INFO_NOTIFICATION_ID, builder.build()) + } + fun hideProgress() { notificationManager.cancel(PROGRESS_NOTIFICATION_ID) } + + fun cancelAll() { + notificationManager.cancelAll() + } } 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..1db794499 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 @@ -4,6 +4,8 @@ package com.theveloper.pixelplay.data.ai import com.theveloper.pixelplay.data.DailyMixManager import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository +import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository +import com.theveloper.pixelplay.data.stats.StatsTimeRange import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import javax.inject.Inject @@ -11,9 +13,10 @@ 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 statsRepository: PlaybackStatsRepository, private val json: Json ) { @@ -42,24 +45,42 @@ class AiPlaylistGenerator @Inject constructor( // Token Optimization: Reduce sample size based on safe mode val isSafe = preferencesRepo.isSafeTokenLimitEnabled.first() + val maxContext = preferencesRepo.getMaxSongsForContextOnce() 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 + // Enrich with play stats from repository + val summary = statsRepository.loadSummary(StatsTimeRange.ALL, allSongs) + val events = statsRepository.exportEventsForBackup() + val nowMs = System.currentTimeMillis() + val songPlayMap = summary.songs.associateBy { it.songId } + val lastPlayedMap = events + .groupBy { it.songId } + .mapValues { (_, evts) -> evts.maxOf { it.timestamp } } + + // Token Optimization: Compact JSON format — rich fields for AI curation val availableSongsJson = buildString { songSample.forEachIndexed { index, song -> val score = dailyMixManager.getScore(song.id) val title = song.title.replace("\"", "'").take(40) val artist = song.displayArtist.replace("\"", "'").take(25) - val genre = song.genre?.replace("\"", "'")?.take(15) ?: "?" + val genre = (song.genre ?: "?").replace("\"", "'").take(15) + val album = song.album.replace("\"", "'").take(20) + val durationSec = song.duration / 1000 + val year = song.year.toString() + val fav = if (song.isFavorite) "1" else "0" + val playStats = songPlayMap[song.id] + val pc = playStats?.playCount ?: 0 + val lastPlayed = lastPlayedMap[song.id] + val lh = if (lastPlayed != null) ((nowMs - lastPlayed) / 3600000).toInt().coerceAtMost(9999) else -1 if (index > 0) append(",\n") - append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","s":$score}""") + append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","al":"$album","d":$durationSec,"y":"$year","f":$fav,"s":$score,"pc":$pc,"lh":$lh}""") } } // Bring in the telemetry digest - val userDigest = digestGenerator.generateDigest(allSongs, isSafe) + val userDigest = digestGenerator.generateDigest(allSongs, isSafe, maxContext) // Token Optimization: Compact prompt structure with XML data boundaries val fullPrompt = """ @@ -73,7 +94,7 @@ class AiPlaylistGenerator @Inject constructor( """.trimIndent() - val responseText = aiOrchestrator.generateContent(fullPrompt, type) + val responseText = aiHandler.generateContent(fullPrompt, type) val songIds = extractPlaylistSongIds(responseText) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSettingsManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSettingsManager.kt new file mode 100644 index 000000000..ebc635469 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSettingsManager.kt @@ -0,0 +1,280 @@ +package com.theveloper.pixelplay.data.ai + +import android.content.Context +import com.theveloper.pixelplay.data.ai.local.LocalModelManager +import com.theveloper.pixelplay.data.ai.local.LocalModelCatalog +import com.theveloper.pixelplay.data.ai.local.LocalModelInfo +import com.theveloper.pixelplay.data.ai.local.ModelSource +import com.theveloper.pixelplay.data.ai.local.ModelStatus +import com.theveloper.pixelplay.data.ai.provider.AiProvider +import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Central manager for all AI-related settings and preferences. + * Coordinates between cloud AI, local models, and user preferences. + */ +@Singleton +class AiSettingsManager @Inject constructor( + @ApplicationContext private val context: Context, + private val aiPreferencesRepository: AiPreferencesRepository, + private val localMlManager: LocalModelManager, + private val aiDeviceCapabilities: AiDeviceCapabilities +) { + data class AiSettingsState( + val activeProvider: String = "GEMINI", + val activeModel: String = "gemini-2.0-flash-exp", + val temperature: Float = 0.7f, + val maxTokens: Int = 2048, + val enableStreaming: Boolean = true, + val includeContext: Boolean = true, + val contextWindowSize: Int = 50, + val includeLikedSongs: Boolean = true, + val includeDailyMixHistory: Boolean = true, + val includeUserHabits: Boolean = true, + val localModelEnabled: Boolean = false, + val localModelId: String? = null, + val ollamaEndpoint: String = "https://ollama.ai/api", + val huggingFaceToken: String? = null, + val topK: Int = 40, + val topP: Float = 0.95f, + val repetitionPenalty: Float = 1.0f, + val frequencyPenalty: Float = 0.0f, + val presencePenalty: Float = 0.0f + ) + + private val _settingsState = MutableStateFlow(AiSettingsState()) + val settingsState: StateFlow = _settingsState.asStateFlow() + + private val _availableModels = MutableStateFlow>(emptyList()) + val availableModels: StateFlow> = _availableModels.asStateFlow() + + /** + * Loads settings from preferences. + */ + suspend fun loadSettings() { + try { + val provider = aiPreferencesRepository.aiProvider.first() + val model = aiPreferencesRepository.getModel(AiProvider.fromString(provider)).first() + + _settingsState.value = AiSettingsState( + activeProvider = provider, + activeModel = model, + temperature = aiPreferencesRepository.aiTemperature.first() / 100f, + maxTokens = aiPreferencesRepository.aiMaxTokens.first(), + enableStreaming = aiPreferencesRepository.aiEnableStreaming.first(), + includeContext = aiPreferencesRepository.aiIncludeContext.first(), + contextWindowSize = aiPreferencesRepository.maxSongsForContext.first(), + includeLikedSongs = aiPreferencesRepository.includeLikedSongs.first(), + includeDailyMixHistory = aiPreferencesRepository.includeDailyMixHistory.first(), + includeUserHabits = aiPreferencesRepository.includeUserHabits.first(), + localModelEnabled = aiPreferencesRepository.localMlEnabled.first(), + localModelId = aiPreferencesRepository.localMlActiveModelId.first().takeIf { it.isNotEmpty() }, + ollamaEndpoint = aiPreferencesRepository.localMlOllamaUrl.first(), + huggingFaceToken = aiPreferencesRepository.localMlHfToken.first().takeIf { it.isNotEmpty() }, + topK = aiPreferencesRepository.aiTopK.first(), + topP = aiPreferencesRepository.aiTopP.first() / 100f, + repetitionPenalty = aiPreferencesRepository.aiRepetitionPenalty.first() / 100f, + frequencyPenalty = aiPreferencesRepository.aiFrequencyPenalty.first() / 100f, + presencePenalty = aiPreferencesRepository.aiPresencePenalty.first() / 100f + ) + + // Load available models based on device capabilities + refreshAvailableModels() + } catch (e: Exception) { + // Handle loading error - keep default state + e.printStackTrace() + } + } + + /** + * Refreshes the list of available models for this device. + */ + fun refreshAvailableModels() { + val capabilities = aiDeviceCapabilities.getCapabilities() + val catalogModels = LocalModelCatalog.all + + // Filter by device capabilities - only show models that fit in device RAM + val filteredModels = catalogModels.filter { model: LocalModelInfo -> + val sizeMb = if (model.fileSizeBytes > 0) model.fileSizeBytes / (1024 * 1024) else 0 + capabilities.recommendedModelSizeMb >= sizeMb + } + + _availableModels.value = filteredModels + } + + /** + * Gets all available models from all sources. + */ + fun getAllModelSources(): Map> { + return _availableModels.value.groupBy { it.source } + } + + /** + * Gets models from a specific source. + */ + fun getModelsBySource(source: ModelSource): List { + return _availableModels.value.filter { it.source == source } + } + + /** + * Updates a single setting. + */ + suspend fun updateSetting(block: AiSettingsState.() -> AiSettingsState) { + val newState = block(_settingsState.value) + _settingsState.value = newState + + // Persist to preferences + aiPreferencesRepository.setAiProvider(newState.activeProvider) + aiPreferencesRepository.setModel(AiProvider.fromString(newState.activeProvider), newState.activeModel) + aiPreferencesRepository.setAiTemperature((newState.temperature * 100).toInt()) + aiPreferencesRepository.setAiMaxTokens(newState.maxTokens) + aiPreferencesRepository.setAiEnableStreaming(newState.enableStreaming) + aiPreferencesRepository.setAiIncludeContext(newState.includeContext) + aiPreferencesRepository.setMaxSongsForContext(newState.contextWindowSize) + aiPreferencesRepository.setIncludeLikedSongs(newState.includeLikedSongs) + aiPreferencesRepository.setIncludeDailyMixHistory(newState.includeDailyMixHistory) + aiPreferencesRepository.setIncludeUserHabits(newState.includeUserHabits) + aiPreferencesRepository.setLocalMlEnabled(newState.localModelEnabled) + aiPreferencesRepository.setLocalMlActiveModelId(newState.localModelId ?: "") + aiPreferencesRepository.setLocalMlOllamaUrl(newState.ollamaEndpoint) + aiPreferencesRepository.setLocalMlHfToken(newState.huggingFaceToken ?: "") + aiPreferencesRepository.setAiTopK(newState.topK) + aiPreferencesRepository.setAiTopP((newState.topP * 100).toInt()) + aiPreferencesRepository.setAiRepetitionPenalty((newState.repetitionPenalty * 100).toInt()) + aiPreferencesRepository.setAiFrequencyPenalty((newState.frequencyPenalty * 100).toInt()) + aiPreferencesRepository.setAiPresencePenalty((newState.presencePenalty * 100).toInt()) + } + + suspend fun set(block: AiSettingsState.() -> AiSettingsState) { updateSetting(block) } + + /** + * Downloads and sets up a local model. + */ + suspend fun setupLocalModel(modelId: String): Boolean { + return try { + // Find the model info from catalog + val modelInfo = LocalModelCatalog.all.find { it.id == modelId } + if (modelInfo == null) { + Timber.tag("AiSettingsManager").e("Model not found in catalog: $modelId") + return false + } + + // Download and wait for completion + val finalStatus = localMlManager.downloadAndWait(modelInfo) + Timber.tag("AiSettingsManager").d("Download completed with status: $finalStatus") + + // Check if download was successful + val isReady = finalStatus is ModelStatus.Ready || localMlManager.isInstalled(modelId) + if (isReady) { + set { copy(localModelId = modelId, localModelEnabled = true) } + true + } else { + Timber.tag("AiSettingsManager").e("Download failed: $finalStatus") + false + } + } catch (e: Exception) { + Timber.tag("AiSettingsManager").e(e, "Failed to setup local model: $modelId") + false + } + } + + /** + * Gets the current provider's model options. + */ + fun getProviderModels(provider: String): List { + val aiProvider = try { AiProvider.valueOf(provider) } catch (_: Exception) { null } + return aiProvider?.models?.ifEmpty { + when (provider) { + "LOCAL" -> _availableModels.value.map { it.id } + else -> emptyList() + } + } ?: emptyList() + } + + /** + * Checks if the current provider is ready for API calls. + */ + fun isProviderReady(): Boolean { + val state = _settingsState.value + return when (state.activeProvider) { + "LOCAL" -> { + val modelId = state.localModelId + state.localModelEnabled && modelId != null && localMlManager.isInstalled(modelId) + } + "OLLAMA" -> state.ollamaEndpoint.isNotBlank() + else -> true // Cloud providers assume API keys are set elsewhere + } + } + + /** + * Validates if current settings can make API calls. + */ + suspend fun validateSettings(): ValidationResult { + val state = _settingsState.value + + return when (state.activeProvider) { + "LOCAL" -> { + if (!state.localModelEnabled) { + ValidationResult.Error("Local models are disabled") + } else { + val modelId = state.localModelId + if (modelId == null) { + ValidationResult.Error("No local model selected") + } else if (!localMlManager.isInstalled(modelId)) { + ValidationResult.Error("Selected model not downloaded") + } else { + ValidationResult.Valid + } + } + } + "OLLAMA" -> { + if (state.ollamaEndpoint.isBlank()) { + ValidationResult.Error("Ollama endpoint not configured") + } else { + ValidationResult.Valid + } + } + "GEMINI", "OPENAI", "ANTHROPIC" -> { + // API key validation happens at the provider level + ValidationResult.Valid + } + else -> ValidationResult.Error("Unknown provider: ${state.activeProvider}") + } + } + + /** + * Gets the status of a specific local model. + */ + fun getModelStatus(modelId: String): ModelStatus { + return localMlManager.getModelStatus(modelId) + } + + /** + * Deletes a downloaded local model. + */ + suspend fun deleteLocalModel(modelId: String): Boolean { + return try { + val success = localMlManager.deleteModel(modelId) + if (success && _settingsState.value.localModelId == modelId) { + set { copy(localModelId = null, localModelEnabled = false) } + } + success + } catch (e: Exception) { + false + } + } + + sealed class ValidationResult { + object Valid : ValidationResult() + data class Error(val message: String) : ValidationResult() + } +} \ No newline at end of file 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..f309567b6 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 @@ -6,7 +6,6 @@ import javax.inject.Singleton enum class AiSystemPromptType { PLAYLIST, - METADATA, TAGGING, MOOD_ANALYSIS, PERSONA, @@ -17,113 +16,87 @@ 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. - - """.trimIndent() - fun buildPrompt(basePersona: String, type: AiSystemPromptType, context: String = ""): String { + val 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. +- If no valid output can be produced, return the neutral/empty form for the schema. + """.trimIndent() + val requirementLayer = when (type) { AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> """ - Music curation engine mapping user requests to a strict candidate pool. - - - 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. - - - Return ONLY a raw JSON array of song IDs representing the playlist sequence. - Format: ["id_1","id_2","id_3"] - - """.trimIndent() - - AiSystemPromptType.METADATA -> """ - Precision music metadata specialist. - - - Fix spelling errors and standardizations in song titles and artists. - - Replace generic genres ("Music", "Electronic") with highly specific subgenres ("Synthwave", "Nu-Disco"). - - - Return ONLY a raw JSON object string. - Format: {"title":"Clean Title", "artist":"Primary Artist", "album":"Album Name", "genre":"Specific Genre"} - +Music curation engine mapping user requests to a strict candidate pool. + +- Decode LISTENER SIGNALS from USER_PROFILE: STATS, GENRES, ARTISTS, PHASE, VAR. +- If VAR < 0.3 prioritize familiar tracks; if VAR > 0.7 lean into DISCOVERY_POOL. +- Blend pools using PHASE (morning=upbeat, evening=chill, night=deep). +- Build a journey: opening (set vibe) -> body (narrative arc) -> closing (resolve). +- Respect target_length; never repeat a song ID within a single playlist. + +Return ONLY a raw JSON array of song IDs. Format: ["id_1","id_2","id_3"]. On error: [] """.trimIndent() AiSystemPromptType.TAGGING -> """ - Atmospheric audio tagging engine. - - - Generate exactly 6-10 highly descriptive, hyphenated acoustic tags. - - Focus on mood, instrumentation, pace, and sonic texture. - - All tags must be strictly lowercase. - - - Return ONLY a raw comma-separated text list. - Format: cinematic, atmospheric-build, dark-synth, driving-beat - +Atmospheric audio tagging engine. + +- Generate 6-10 hyphenated acoustic tags (mood, instrumentation, pace, texture). +- Consider track duration, genre, year, and play count. +- Tags must be lowercase, single words or hyphenated compounds. No punctuation or numbers. + +Return ONLY a comma-separated list. Format: cinematic, atmospheric-build, dark-synth, driving-beat. On error: unknown """.trimIndent() AiSystemPromptType.MOOD_ANALYSIS -> """ - Algorithmic audio sentiment analyzer. - - - 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. - - - Return ONLY the exact structured text format. - Format: PrimaryMood | Energy:0.9 | Valence:0.1 | Danceability:0.4 | Acousticness:0.0 - +Algorithmic audio sentiment analyzer. + +- Primary moods: Joyful, Aggressive, Calm, Melancholic, Radiant, Intense, Somber, Playful, Ethereal. +- Energy: electronic/high-BPM >= 0.7, acoustic/slow <= 0.3. Valence: major/upbeat >= 0.6, minor/dark <= 0.4. +- Danceability: dance/pop high, ambient/classical low. Acousticness: electronic low, orchestral high. + +Return ONE line: PrimaryMood | Energy:0.85 | Valence:0.72 | Danceability:0.64 | Acousticness:0.12. On error: Neutral | Energy:0.5 | Valence:0.5 | Danceability:0.5 | Acousticness:0.5 """.trimIndent() AiSystemPromptType.PERSONA -> """ - Daily Mix professional curator. You represent the persona: "$basePersona" - - - Speak directly to the listener's tastes using their data. - - 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. - +Daily Mix curator. Persona: "$basePersona" + +- Speak directly to the listener using their data. Reference play counts, genre shifts, time-of-day habits. +- Sophisticated, empathetic tone. 2-4 paragraphs. No programmatic constraints — conversational allowed. + """.trimIndent() AiSystemPromptType.GENERAL -> """ - PixelPlayer Assistant - - Assist the user with any complex queries or actions inside their music ecosystem. - +PixelPlayer Assistant + +- Assist with any complex queries inside the user's music ecosystem. +- Provide helpful, informed answers using their profile, library stats, and available songs. +- If generating playlists or recommendations, describe your reasoning. + """.trimIndent() } val contextLayer = if (context.isNotBlank()) { """ - - $context - - - LISTENED Format: id|play_count|duration_mins|is_fav|metadata - DISCOVERY Format: unplayed candidate tracks - +$context + +USER_PROFILE: STATS (total_plays|unique_songs), GENRES (top 3), ARTISTS (top 5), PHASE (Morning/Afternoon/Evening/Night), VAR (0.0-1.0), PL (playlist names) +LISTENED: song_id|play_count|total_duration_mins|is_favorite(0/1)|title-artist +DISCOVERY_POOL: song_id|title-artist — unplayed candidate tracks + """.trimIndent() } else "" - val systemBlock = """ - - $basePersona - $requirementLayer - - """.trimIndent() + val systemBlock = "\n$basePersona\n$requirementLayer\n" - // 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) { - listOf(systemBlock, contextLayer).filter { it.isNotBlank() }.joinToString("\n\n") - } else { - listOf(systemBlock, UNIVERSAL_CONSTRAINTS, contextLayer).filter { it.isNotBlank() }.joinToString("\n\n") + return buildString { + appendLine(systemBlock) + if (type != AiSystemPromptType.PERSONA && type != AiSystemPromptType.GENERAL && constraints.isNotBlank()) { + appendLine() + appendLine(constraints) + } + if (contextLayer.isNotBlank()) { + appendLine() + appendLine(contextLayer) + } } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiUsageAnalytics.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiUsageAnalytics.kt new file mode 100644 index 000000000..908b767fa --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiUsageAnalytics.kt @@ -0,0 +1,48 @@ +package com.theveloper.pixelplay.data.ai + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "api_call_records") +data class ApiCallRecord( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val timestamp: Long, + val provider: String, + val model: String, + val inputTokens: Int, + val outputTokens: Int, + val latencyMs: Long, + val success: Boolean, + val requestType: String = "unknown" +) + +data class UsageStats( + val totalInputTokens: Int = 0, + val totalOutputTokens: Int = 0, + val totalCalls: Int = 0, + val successRate: Float = 0f, + val avgLatencyMs: Long = 0L, + val costEstimate: Double = 0.0 +) + +data class ModelSettings( + val modelName: String, + val temperature: Float? = null, + val maxTokens: Int? = null, + val systemPrompt: String? = null, + val retryAttempts: Int = 3, + val timeoutMs: Int = 60000, + val enabled: Boolean = true +) + +data class ProviderPricing( + val provider: String, + val inputTokenCostPerMillion: Double = 0.0, + val outputTokenCostPerMillion: Double = 0.0 +) { + fun estimateCost(inputTokens: Long, outputTokens: Long): Double { + return (inputTokens * inputTokenCostPerMillion / 1_000_000) + + (outputTokens * outputTokenCostPerMillion / 1_000_000) + } +} 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 deleted file mode 100644 index 5e96bc870..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.theveloper.pixelplay.data.ai - -import com.theveloper.pixelplay.data.repository.MusicRepository -import com.theveloper.pixelplay.data.worker.AiWorkerManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -data class GeminiModel( - val name: String, - val displayName: String -) - -@Singleton -class GeminiModelService @Inject constructor( - private val orchestrator: AiOrchestrator, - private val digestGenerator: UserProfileDigestGenerator, - private val musicRepository: MusicRepository, - private val workerManager: AiWorkerManager -) { - - suspend fun fetchAvailableModels(apiKey: String): Result> { - return withContext(Dispatchers.IO) { - try { - if (apiKey.isBlank()) { - return@withContext Result.failure(Exception("API Key is required")) - } - val response = makeModelsListRequest(apiKey) - Result.success(response) - } catch (e: Exception) { - Timber.e(e, "Error fetching Gemini models") - Result.failure(e) - } - } - } - - private suspend fun makeModelsListRequest(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val url = "https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey" - val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection - - connection.requestMethod = "GET" - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - - val responseCode = connection.responseCode - val apiModels = if (responseCode == 200) { - val response = connection.inputStream.bufferedReader().use { it.readText() } - parseModelsResponse(response) - } else emptyList() - - val defaults = getDefaultModels() - (apiModels + defaults).distinctBy { it.name }.sortedWith( - compareBy { model -> - val preferred = defaults.map { it.name } - preferred.indexOf(model.name).takeIf { it >= 0 } ?: Int.MAX_VALUE - }.thenBy { it.displayName.lowercase() } - ) - } catch (e: Exception) { - getDefaultModels() - } - } - } - - private fun parseModelsResponse(jsonResponse: String): List { - try { - val models = mutableListOf() - val modelPattern = """"name":\s*"(models/[^"]+)"""".toRegex() - val matches = modelPattern.findAll(jsonResponse) - - val blacklist = listOf("-2.0", "-2.5", "-preview", "customtools", "search", "tuning", "-001", "-002") - val whitelist = listOf("gemini-3.1-pro-preview") - - for (match in matches) { - val fullName = match.groupValues[1] - val modelName = fullName.removePrefix("models/") - - val isWhitelisted = whitelist.any { modelName == it } - val hasForbiddenSuffix = blacklist.any { modelName.contains(it) } - val isBlacklisted = hasForbiddenSuffix && !isWhitelisted - - if (!isBlacklisted && - (modelName.startsWith("gemini", ignoreCase = true) || - modelName.startsWith("gemma", ignoreCase = true)) && - !modelName.contains("embedding", ignoreCase = true)) { - models.add(GeminiModel( - name = modelName, - displayName = formatDisplayName(modelName) - )) - } - } - return models - } catch (e: Exception) { - return emptyList() - } - } - - fun estimateTokens(text: String): Int { - return (text.length / 4).coerceAtLeast(1) - } - - suspend fun performAiTask( - prompt: String, - type: AiSystemPromptType, - runInBackground: Boolean = false, - temperature: Float = 0.7f - ): String? { - if (runInBackground) { - workerManager.enqueueAiTask(prompt, type, temperature) - return null - } else { - val allSongs = musicRepository.getAllSongsOnce() - val context = if (type == AiSystemPromptType.PLAYLIST || - type == AiSystemPromptType.TAGGING || - type == AiSystemPromptType.PERSONA) { - digestGenerator.generateDigest(allSongs) - } else "" - - return orchestrator.generateContent( - prompt = prompt, - type = type, - temperature = temperature, - context = context - ) - } - } - - private fun formatDisplayName(modelName: String): String { - return modelName - .split("-") - .joinToString(" ") { word -> - word.replaceFirstChar { it.uppercase() } - } - } - - private fun getDefaultModels(): List { - return listOf( - GeminiModel("gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite (Recommended Default)"), - GeminiModel("gemini-3.5-flash", "Gemini 3.5 Flash"), - GeminiModel("gemini-3.1-pro-preview", "Gemini 3.1 Pro (Preview)"), - GeminiModel("gemini-flash-lite-latest", "Gemini Flash Lite Latest"), - GeminiModel("gemini-flash-latest", "Gemini Flash Latest"), - GeminiModel("gemma-4-31b-it", "Gemma 4 31B IT"), - GeminiModel("gemma-4-26b-a4b-it", "Gemma 4 26B MoE") - ) - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/HuggingFaceClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/HuggingFaceClient.kt new file mode 100644 index 000000000..33aaf6f5f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/HuggingFaceClient.kt @@ -0,0 +1,152 @@ +package com.theveloper.pixelplay.data.ai + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Client for HuggingFace Hub - primarily for embeddings and text models. + * Uses HuggingFace Inference API for cloud inference. + */ +@Singleton +class HuggingFaceClient @Inject constructor( + @ApplicationContext private val context: Context, + private val aiLogger: AiLogger +) { + companion object { + const val HF_INFERENCE_URL = "https://api-inference.huggingface.co" + const val HF_HUB_URL = "https://huggingface.co/api" + } + + private var apiToken: String = "" + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + fun configure(token: String) { + this.apiToken = token + } + + fun getToken(): String = apiToken + fun hasToken(): Boolean = apiToken.isNotBlank() + + /** + * Validate API token + */ + suspend fun validateToken(): Boolean = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("$HF_HUB_URL/whoami-v2") + .addHeader("Authorization", "Bearer $apiToken") + .get() + .build() + + client.newCall(request).execute().use { it.isSuccessful } + } catch (e: Exception) { + false + } + } + + /** + * Fetch popular text embedding models + */ + suspend fun fetchEmbeddingModels(): List = withContext(Dispatchers.IO) { + fetchModelsByTask("feature-extraction", 15) + } + + /** + * Fetch popular text generation models + */ + suspend fun fetchGenerationModels(): List = withContext(Dispatchers.IO) { + fetchModelsByTask("text-generation", 10) + } + + private suspend fun fetchModelsByTask(task: String, limit: Int): List { + return try { + val request = Request.Builder() + .url("$HF_HUB_URL/models?sort=downloads&direction=-1&limit=$limit&filter=$task") + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return emptyList() + + val body = response.body?.string() ?: return emptyList() + val jsonArray = JSONArray(body) + val models = mutableListOf() + + for (i in 0 until jsonArray.length()) { + val m = jsonArray.getJSONObject(i) + if (!m.optBoolean("private", false)) { + models.add(HFModel( + id = m.getString("id"), + downloads = m.optInt("downloads", 0), + task = task + )) + } + } + models.sortedByDescending { it.downloads } + } + } catch (e: Exception) { + Timber.e(e, "Failed to fetch $task models") + defaultModels().filter { it.task == task } + } + } + + /** + * Generate embeddings using HF Inference API + */ + suspend fun generateEmbedding(modelId: String, text: String): List = withContext(Dispatchers.IO) { + val body = JSONObject().put("inputs", text).toString() + val request = Request.Builder() + .url("$HF_INFERENCE_URL/models/$modelId") + .addHeader("Authorization", "Bearer $apiToken") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Exception("API error: ${response.code}") + val responseBody = response.body?.string() ?: throw Exception("Empty response") + + // Handle array response + val json = JSONArray(responseBody) + val embedding = json.getJSONArray(0) + val result = mutableListOf() + for (i in 0 until embedding.length()) { + result.add(embedding.getDouble(i).toFloat()) + } + result + } + } + + /** + * Default models when API is unavailable + */ + fun defaultModels(): List = listOf( + HFModel("sentence-transformers/all-MiniLM-L6-v2", 2_000_000, "feature-extraction"), + HFModel("BAAI/bge-small-en-v1.5", 1_500_000, "feature-extraction"), + HFModel("microsoft/Phi-3-mini-4k-instruct", 1_000_000, "text-generation"), + HFModel("TinyLlama/TinyLlama-1.1B-Chat-v1.0", 500_000, "text-generation"), + HFModel("google/gemma-2b-it", 800_000, "text-generation") + ) + + data class HFModel( + val id: String, + val downloads: Int, + val task: String + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/OllamaClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/OllamaClient.kt new file mode 100644 index 000000000..1ef94b388 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/OllamaClient.kt @@ -0,0 +1,90 @@ +package com.theveloper.pixelplay.data.ai + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OllamaClient @Inject constructor( + @ApplicationContext private val context: Context, + private val aiLogger: AiLogger +) { + companion object { const val DEFAULT_BASE_URL = "http://localhost:11434" } + + private var baseUrl: String = DEFAULT_BASE_URL + private var apiKey: String = "" + private var model: String = "llama3" + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS).readTimeout(180, TimeUnit.SECONDS).writeTimeout(30, TimeUnit.SECONDS).build() + + fun configure(endpoint: String, apiKey: String = "", defaultModel: String = "llama3") { + this.baseUrl = endpoint.trimEnd('/'); this.apiKey = apiKey; this.model = defaultModel + Timber.d("Ollama configured: $baseUrl, model: $model") + } + + fun getConfiguration() = OllamaConfig(baseUrl, apiKey, model) + + suspend fun fetchModels(): List = withContext(Dispatchers.IO) { + try { + client.newCall(request("/api/tags").get().build()).execute().use { response -> + if (!response.isSuccessful) return@withContext listOf(model) + val json = JSONObject(response.body?.string() ?: return@withContext listOf(model)) + val models = json.getJSONArray("models") + (0 until models.length()).map { models.getJSONObject(it).getString("name") }.ifEmpty { listOf(model) } + } + } catch (_: Exception) { listOf(model) } + } + + fun isServerAvailable(): Boolean = try { + client.newCall(request("/api/tags").get().build()).execute().use { it.isSuccessful } + } catch (_: Exception) { false } + + suspend fun generateContent(prompt: String, systemPrompt: String = "", temperature: Float = 0.7f, modelName: String = model): String = + withContext(Dispatchers.IO) { + val body = JSONObject().apply { + put("model", modelName) + put("messages", JSONArray(buildList { + if (systemPrompt.isNotBlank()) add(JSONObject().apply { put("role", "system"); put("content", systemPrompt) }) + add(JSONObject().apply { put("role", "user"); put("content", prompt) }) + })) + put("temperature", temperature.toDouble()) + put("stream", false) + }.toString().toRequestBody("application/json".toMediaType()) + try { + client.newCall(request("/chat/completions").post(body).build()).execute().use { response -> + val rb = response.body?.string() ?: throw Exception("Empty response") + if (!response.isSuccessful) throw Exception("Ollama error ${response.code}: ${response.message}") + JSONObject(rb).getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content") + } + } catch (e: Exception) { Timber.e(e, "Ollama generation failed"); throw e } + } + + suspend fun generateEmbedding(text: String, modelName: String = model): List = withContext(Dispatchers.IO) { + val body = JSONObject().apply { put("model", modelName); put("prompt", text) } + .toString().toRequestBody("application/json".toMediaType()) + client.newCall(request("/embeddings").post(body).build()).execute().use { response -> + val rb = response.body?.string() ?: throw Exception("Empty response") + if (!response.isSuccessful) throw Exception("Embedding error: ${response.code}") + val arr = JSONObject(rb).getJSONArray("embedding") + (0 until arr.length()).map { arr.getDouble(it).toFloat() } + } + } + + private fun request(path: String) = Request.Builder().url("$baseUrl$path").apply { + if (apiKey.isNotBlank()) addHeader("Authorization", "Bearer $apiKey") + } + + data class OllamaConfig(val endpoint: String, val apiKey: String, val defaultModel: String) +} 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..3fbfad453 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 @@ -14,38 +14,45 @@ class UserProfileDigestGenerator @Inject constructor( private val statsRepository: PlaybackStatsRepository, private val playlistDao: LocalPlaylistDao ) { - // 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 { + suspend fun generateDigest( + allSongs: List, + isSafeLimit: Boolean = true, + maxSongsForContext: Int = 50 + ): 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 listenedLimit = if (isSafeLimit) { + (maxSongsForContext * 0.3).toInt().coerceIn(SAFE_LISTENED_LIMIT, FULL_LISTENED_LIMIT) + } else { + (maxSongsForContext * 0.5).toInt().coerceIn(FULL_LISTENED_LIMIT, 200) + } + val discoveryLimit = if (isSafeLimit) { + (maxSongsForContext * 0.6).toInt().coerceIn(SAFE_DISCOVERY_LIMIT, FULL_DISCOVERY_LIMIT) + } else { + maxSongsForContext.coerceIn(FULL_DISCOVERY_LIMIT, 400) + } + val recentLimit = if (isSafeLimit) 5 else 15 val summary = statsRepository.loadSummary(StatsTimeRange.ALL, allSongs) + val history = statsRepository.loadPlaybackHistory(50) + val events = statsRepository.exportEventsForBackup() 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") + // --- 1. Behavioral & Pattern Metrics --- + val totalSkips = estimateTotalSkips(summary) + val totalCompletions = summary.totalPlayCount - totalSkips + val completionRate = if (summary.totalPlayCount > 0) ((totalCompletions.toFloat() / summary.totalPlayCount) * 100).toInt() else 85 + sb.append("STATS: plays=${summary.totalPlayCount}, uniq=${summary.uniqueSongs}, skip=${totalSkips}, comp=${completionRate}%\n") sb.append("GENRES: ${summary.topGenres.take(3).joinToString(",") { it.genre }}\n") sb.append("ARTISTS: ${summary.topArtists.take(5).joinToString(",") { it.artist }}\n") @@ -64,17 +71,41 @@ class UserProfileDigestGenerator @Inject constructor( val variety = if (summary.totalPlayCount > 0) summary.uniqueSongs.toDouble() / summary.totalPlayCount else 0.0 sb.append("VAR: ${"%.2f".format(variety)}\n") - + + val genreCompletion = summary.topGenres.take(3).map { g -> + val genreSongs = summary.songs.filter { s -> allSongs.find { it.id == s.songId }?.genre == g.genre } + val genrePlays = genreSongs.sumOf { it.playCount } + val genreSkips = estimateSkipCountForSongs(genreSongs) + val rate = if (genrePlays > 0) ((genrePlays - genreSkips).toFloat() / genrePlays * 100).toInt() else 0 + "${g.genre}:${rate}%" + } + if (genreCompletion.isNotEmpty()) { + sb.append("GENRE_COMP: ${genreCompletion.joinToString(",")}\n") + } + val playlistLimit = if (isSafeLimit) 5 else 20 if (playlists.isNotEmpty()) { sb.append("PL: ${playlists.take(playlistLimit).joinToString(",") { it.playlist.name }}\n") } + + // --- 1b. Recently Played (compact, with timestamp) --- + val songMap = allSongs.associateBy { it.id } + val recentIds = history.map { it.songId }.distinct().take(recentLimit) + if (recentIds.isNotEmpty()) { + sb.append("\nRECENT: id|hrs_ago|p\n") + val now = System.currentTimeMillis() + recentIds.forEach { id -> + if (sb.length >= (targetLimit * 0.5).toInt()) return@forEach + val lastEvent = events.filter { it.songId == id }.maxByOrNull { it.timestamp } + val stats = summary.songs.find { it.songId == id } + val hrsAgo = if (lastEvent != null) ((now - lastEvent.timestamp) / 3600000).toInt() else 999 + sb.append("$id|${hrsAgo}h|${stats?.playCount ?: 1}\n") + } + } // --- 2. Listened Tracks (capped) --- - // Compact format: ID|plays|mins|fav|title-artist - sb.append("\nLISTENED: id|p|d|f|meta\n") + sb.append("\nLISTENED: id|p|d_s|f|alb|dur|g|meta\n") - val songMap = allSongs.associateBy { it.id } val playedSongs = summary.songs.take(listenedLimit) playedSongs.forEach { s -> @@ -82,29 +113,51 @@ class UserProfileDigestGenerator @Inject constructor( 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 album = song?.album?.take(20)?.replace("|", "/") ?: "?" + val durationSec = if (song != null) song.duration / 1000 else 0 + val genre = song?.genre?.take(12)?.replace("|", "/") ?: "?" val title = s.title.take(30) val artist = s.artist.take(20) - sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist\n") + sb.append("${s.songId}|${s.playCount}|$mins|$fav|$album|$durationSec|$genre|$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") + sb.append("\nDISCOVERY_POOL: id|alb|dur|g|meta\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") + val album = s.album.take(20).replace("|", "/") + val durationSec = s.duration / 1000 + val genre = (s.genre ?: "?").take(12).replace("|", "/") + sb.append("${s.id}|$album|$durationSec|$genre|$title-$artist\n") } } return sb.toString() } + + private fun estimateTotalSkips(summary: PlaybackStatsRepository.PlaybackStatsSummary): Int { + return summary.songs.sumOf { s -> + val expectedAvg = if (s.playCount > 0) s.totalDurationMs / maxOf(s.playCount, 1).toFloat() else 0f + if (expectedAvg > 30_000f && s.playCount > 1) { + (s.playCount * 0.15f).toInt().coerceAtMost(s.playCount - 1) + } else 0 + } + } + + private fun estimateSkipCountForSongs(songs: List): Int { + return songs.sumOf { s -> + val expectedAvg = if (s.playCount > 0) s.totalDurationMs / maxOf(s.playCount, 1).toFloat() else 0f + if (expectedAvg > 30_000f && s.playCount > 1) { + (s.playCount * 0.15f).toInt().coerceAtMost(s.playCount - 1) + } else 0 + } + } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/local/LocalModelConfig.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/local/LocalModelConfig.kt new file mode 100644 index 000000000..aa8c245db --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/local/LocalModelConfig.kt @@ -0,0 +1,534 @@ +package com.theveloper.pixelplay.data.ai.local + +data class LocalModelInfo( + val id: String, + val displayName: String, + val description: String, + val source: ModelSource, + val downloadUrl: String, + val fileSizeBytes: Long, + val ramRequiredMb: Int, + val type: ModelType, + val format: ModelFormat, + val tags: List = emptyList(), + val isRecommended: Boolean = false, + val huggingFaceRepo: String? = null, + val ollamaTag: String? = null, + val minAndroidVersion: Int = 24 +) + +enum class ModelSource { TFLITE, HUGGINGFACE, ONNX, USER_IMPORTED } +enum class ModelType { EMBEDDING, TEXT_GENERATION, SENTIMENT, CLASSIFICATION } +enum class ModelFormat(val extension: String) { TFLITE("tflite"), ONNX("onnx"), GGUF("gguf"), BIN("bin") } + +sealed class ModelStatus { + object NotDownloaded : ModelStatus() + data class Downloading( + val progress: Int, + val bytesDownloaded: Long, + val totalBytes: Long, + val speedBytesPerSec: Long = 0L + ) : ModelStatus() { + val etaSeconds: Long get() = + if (speedBytesPerSec > 0L && totalBytes > 0L) (totalBytes - bytesDownloaded) / speedBytesPerSec else 0L + } + data class Pending(val reason: String = "Waiting...") : ModelStatus() + object Ready : ModelStatus() + data class Error(val message: String) : ModelStatus() + object Importing : ModelStatus() +} + +enum class DeviceTier(val minRamMb: Int, val maxRamMb: Int, val displayName: String) { + LOW_END(512, 2048, "Low End (2GB RAM)"), + MID_RANGE(2048, 4096, "Mid Range (2-4GB RAM)"), + HIGH_END(4096, 8192, "High End (4-8GB RAM)"), + FLAGSHIP(8192, Int.MAX_VALUE, "Flagship (8GB+ RAM)") +} + +object LocalModelCatalog { + + private fun deviceTier(): DeviceTier { + val totalRam = Runtime.getRuntime().maxMemory() / (1024 * 1024) + return when { + totalRam >= 8192 -> DeviceTier.FLAGSHIP + totalRam >= 4096 -> DeviceTier.HIGH_END + totalRam >= 2048 -> DeviceTier.MID_RANGE + else -> DeviceTier.LOW_END + } + } + + private fun hfBlake(repo: String, file: String) = "https://huggingface.co/$repo/resolve/main/$file" + + private fun qwenGGUF(model: String) = hfBlake("Qwen/Qwen2.5-${model}B-Instruct-GGUF", "qwen2.5-${model}b-instruct-q4_k_m.gguf") + + private fun qwenCoderGGUF(model: String) = hfBlake("Qwen/Qwen2.5-Coder-${model}B-Instruct-GGUF", "qwen2.5-coder-${model}b-instruct-q4_k_m.gguf") + + val all: List = listOf( + // ====================================================================== + // LOW END DEVICES (512MB - 2GB RAM) + // ====================================================================== + + // -- Embeddings (low-end) -- + LocalModelInfo( + id = "allminilm_tiny", displayName = "Tiny Embeddings", + description = "Ultra-light embedding ~25MB. Great for similarity search.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("sentence-transformers/all-MiniLM-L6-v2", "onnx/model_quantized.onnx"), + fileSizeBytes = 25_000_000, ramRequiredMb = 128, + type = ModelType.EMBEDDING, format = ModelFormat.ONNX, isRecommended = true, + tags = listOf("embedding", "tiny", "fast"), huggingFaceRepo = "sentence-transformers/all-MiniLM-L6-v2" + ), + LocalModelInfo( + id = "bge_tiny", displayName = "BGE Tiny Embeddings", + description = "Small but powerful embeddings ~40MB.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("BAAI/bge-small-en-v1.5", "onnx/model_quantized.onnx"), + fileSizeBytes = 40_000_000, ramRequiredMb = 256, + type = ModelType.EMBEDDING, format = ModelFormat.ONNX, isRecommended = true, + tags = listOf("embedding", "bge", "small"), huggingFaceRepo = "BAAI/bge-small-en-v1.5" + ), + + // -- Chat/Generation (≤1.5B, Q4) -- + LocalModelInfo( + id = "qwen2.5_0.5b", displayName = "Qwen 2.5 0.5B (Q4)", + description = "Fastest option ~350MB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenGGUF("0.5"), fileSizeBytes = 350_000_000, ramRequiredMb = 256, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "qwen", "fast"), huggingFaceRepo = "Qwen/Qwen2.5-0.5B-Instruct-GGUF" + ), + LocalModelInfo( + id = "smollm2_1.7b", displayName = "SmolLM2 1.7B (Q4)", + description = "HuggingFace SmolLM2 ~1GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/SmolLM2-1.7B-Instruct-GGUF", "smollm2-1.7b-instruct.Q4_K_M.gguf"), + fileSizeBytes = 1_000_000_000, ramRequiredMb = 512, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "smollm", "huggingface"), huggingFaceRepo = "bartowski/SmolLM2-1.7B-Instruct-GGUF" + ), + LocalModelInfo( + id = "qwen2.5_1.5b", displayName = "Qwen 2.5 1.5B (Q4)", + description = "Good quality/size ratio ~900MB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenGGUF("1.5"), fileSizeBytes = 900_000_000, ramRequiredMb = 768, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "qwen"), huggingFaceRepo = "Qwen/Qwen2.5-1.5B-Instruct-GGUF" + ), + LocalModelInfo( + id = "qwen2.5_coder_1.5b", displayName = "Qwen 2.5 Coder 1.5B (Q4)", + description = "Code-optimized 1.5B ~900MB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenCoderGGUF("1.5"), fileSizeBytes = 900_000_000, ramRequiredMb = 768, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "qwen"), huggingFaceRepo = "Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF" + ), + LocalModelInfo( + id = "tinyllama_1b", displayName = "TinyLlama 1.1B", + description = "Compact Llama-based ~700MB. Great for mobile.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/TinyLlama-1.1B-Chat-v1.0-GGUF", "tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf"), + fileSizeBytes = 700_000_000, ramRequiredMb = 512, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "tiny", "llama"), huggingFaceRepo = "bartowski/TinyLlama-1.1B-Chat-v1.0-GGUF" + ), + LocalModelInfo( + id = "deepseek_coder_1.3b", displayName = "DeepSeek Coder 1.3B (Q4)", + description = "Compact code model ~800MB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/DeepSeek-Coder-1.3B-Instruct-GGUF", "deepseek-coder-1.3b-instruct.Q4_K_M.gguf"), + fileSizeBytes = 800_000_000, ramRequiredMb = 512, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "deepseek"), huggingFaceRepo = "bartowski/DeepSeek-Coder-1.3B-Instruct-GGUF" + ), + LocalModelInfo( + id = "stablelm2_1.6b", displayName = "StableLM 2 1.6B (Q4)", + description = "Stability AI's efficient model ~1GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/stablelm-2-1_6b-zephyr-GGUF", "stablelm-2-1_6b-zephyr.Q4_K_M.gguf"), + fileSizeBytes = 1_000_000_000, ramRequiredMb = 768, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "stable", "stability"), huggingFaceRepo = "bartowski/stablelm-2-1_6b-zephyr-GGUF" + ), + LocalModelInfo( + id = "deepseek_r1_distill_1.5b", displayName = "DeepSeek R1 Distill 1.5B (Q4)", + description = "DeepSeek R1 reasoning distilled to Qwen 1.5B ~900MB. MIT.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/DeepSeek-R1-Distill-Qwen-1.5B-GGUF", "deepseek-r1-distill-qwen-1.5b.Q4_K_M.gguf"), + fileSizeBytes = 900_000_000, ramRequiredMb = 768, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "deepseek", "reasoning"), huggingFaceRepo = "bartowski/DeepSeek-R1-Distill-Qwen-1.5B-GGUF" + ), + + // ====================================================================== + // MID RANGE LOW (2 - 3GB RAM) + // ====================================================================== + + LocalModelInfo( + id = "qwen2.5_coder_0.5b", displayName = "Qwen 2.5 Coder 0.5B (Q4)", + description = "Tiny code model ~350MB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenCoderGGUF("0.5"), fileSizeBytes = 350_000_000, ramRequiredMb = 256, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "qwen", "tiny"), huggingFaceRepo = "Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF" + ), + LocalModelInfo( + id = "phi2_q4", displayName = "Phi-2 (Q4)", + description = "Microsoft Phi-2 2.7B ~1.6GB. Great reasoning.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/phi-2-GGUF", "phi-2.Q4_K_M.gguf"), + fileSizeBytes = 1_600_000_000, ramRequiredMb = 1024, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "phi2"), huggingFaceRepo = "bartowski/phi-2-GGUF" + ), + LocalModelInfo( + id = "gemma_1.1_2b_q4", displayName = "Gemma 1.1 2B (Q4)", + description = "Google Gemma 1.1 2B ~1.2GB.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Gemma-1.1-2B-it-GGUF", "gemma-1.1-2b-it.Q4_K_M.gguf"), + fileSizeBytes = 1_200_000_000, ramRequiredMb = 1024, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "gemma"), huggingFaceRepo = "bartowski/Gemma-1.1-2B-it-GGUF" + ), + LocalModelInfo( + id = "qwen2.5_3b", displayName = "Qwen 2.5 3B (Q4)", + description = "Balanced 3B model ~1.8GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenGGUF("3"), fileSizeBytes = 1_800_000_000, ramRequiredMb = 1024, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "qwen", "balanced"), huggingFaceRepo = "Qwen/Qwen2.5-3B-Instruct-GGUF" + ), + LocalModelInfo( + id = "llama3.2_3b", displayName = "Llama 3.2 3B (Q4)", + description = "Meta Llama 3.2 3B ~1.8GB. Llama 3.2 license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Llama-3.2-3B-Instruct-GGUF", "llama-3.2-3b-instruct.Q4_K_M.gguf"), + fileSizeBytes = 1_800_000_000, ramRequiredMb = 1024, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "llama"), huggingFaceRepo = "bartowski/Llama-3.2-3B-Instruct-GGUF" + ), + LocalModelInfo( + id = "starcoder2_3b", displayName = "StarCoder2 3B (Q4)", + description = "Code gen 3B ~1.8GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/StarCoder2-3B-GGUF", "starcoder2-3b.Q4_K_M.gguf"), + fileSizeBytes = 1_800_000_000, ramRequiredMb = 1024, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "starcoder"), huggingFaceRepo = "bartowski/StarCoder2-3B-GGUF" + ), + LocalModelInfo( + id = "phi3_mini_q4", displayName = "Phi-3 Mini 3.8B (Q4)", + description = "Microsoft Phi-3 Mini 3.8B ~2.3GB. MIT via bartowski.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Phi-3-mini-4k-instruct-GGUF", "phi-3-mini-4k-instruct.Q4_K_M.gguf"), + fileSizeBytes = 2_300_000_000, ramRequiredMb = 1536, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "phi3"), huggingFaceRepo = "bartowski/Phi-3-mini-4k-instruct-GGUF" + ), + LocalModelInfo( + id = "phi3.5_mini", displayName = "Phi-3.5 Mini 3.8B (Q4)", + description = "Microsoft Phi-3.5 Mini ~2.3GB. MIT via bartowski.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Phi-3.5-mini-instruct-GGUF", "phi-3.5-mini-instruct.Q4_K_M.gguf"), + fileSizeBytes = 2_300_000_000, ramRequiredMb = 1536, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "phi3"), huggingFaceRepo = "bartowski/Phi-3.5-mini-instruct-GGUF" + ), + LocalModelInfo( + id = "granite3_2b", displayName = "Granite 3.0 2B (Q4)", + description = "IBM Granite 3.0 2B ~1.3GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Granite-3.0-2B-Instruct-GGUF", "granite-3.0-2b-instruct.Q4_K_M.gguf"), + fileSizeBytes = 1_300_000_000, ramRequiredMb = 1024, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "granite", "ibm"), huggingFaceRepo = "bartowski/Granite-3.0-2B-Instruct-GGUF" + ), + LocalModelInfo( + id = "zephyr_3b", displayName = "Zephyr 3B (Q4)", + description = "HuggingFace Zephyr 3B ~1.8GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Zephyr-3B-GGUF", "zephyr-3b.Q4_K_M.gguf"), + fileSizeBytes = 1_800_000_000, ramRequiredMb = 1536, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "zephyr", "huggingface"), huggingFaceRepo = "bartowski/Zephyr-3B-GGUF" + ), + + // ====================================================================== + // MID RANGE DEVICES (3GB - 4GB RAM) + // ====================================================================== + + // -- Embeddings (mid-range) -- + LocalModelInfo( + id = "allminilm", displayName = "MiniLM Embeddings", + description = "Balanced embedding ~45MB.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("sentence-transformers/all-MiniLM-L6-v2", "onnx/model.onnx"), + fileSizeBytes = 45_000_000, ramRequiredMb = 256, + type = ModelType.EMBEDDING, format = ModelFormat.ONNX, + tags = listOf("embedding", "balanced"), huggingFaceRepo = "sentence-transformers/all-MiniLM-L6-v2" + ), + LocalModelInfo( + id = "bge_base", displayName = "BGE Base Embeddings", + description = "Higher quality embeddings ~170MB.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("BAAI/bge-base-en-v1.5", "onnx/model.onnx"), + fileSizeBytes = 170_000_000, ramRequiredMb = 512, + type = ModelType.EMBEDDING, format = ModelFormat.ONNX, + tags = listOf("embedding", "bge", "quality"), huggingFaceRepo = "BAAI/bge-base-en-v1.5" + ), + + // -- Text Generation (7B-9B) -- + LocalModelInfo( + id = "mistral_7b_v0.2", displayName = "Mistral 7B v0.2 (Q4)", + description = "Mistral 7B v0.2 Instruct ~4.1GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Mistral-7B-Instruct-v0.2-GGUF", "mistral-7b-instruct-v0.2.Q4_K_M.gguf"), + fileSizeBytes = 4_100_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "mistral", "recommended"), huggingFaceRepo = "bartowski/Mistral-7B-Instruct-v0.2-GGUF" + ), + LocalModelInfo( + id = "mistral_7b_v0.3", displayName = "Mistral 7B v0.3 (Q4)", + description = "Mistral 7B v0.3 Instruct ~4.1GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Mistral-7B-Instruct-v0.3-GGUF", "mistral-7b-instruct-v0.3.Q4_K_M.gguf"), + fileSizeBytes = 4_100_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "mistral"), huggingFaceRepo = "bartowski/Mistral-7B-Instruct-v0.3-GGUF" + ), + LocalModelInfo( + id = "openhermes_7b", displayName = "OpenHermes 2.5 7B (Q4)", + description = "Fine-tuned Mistral 7B ~4.1GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/OpenHermes-2.5-Mistral-7B-GGUF", "openhermes-2.5-mistral-7b.Q4_K_M.gguf"), + fileSizeBytes = 4_100_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "mistral", "openhermes"), huggingFaceRepo = "bartowski/OpenHermes-2.5-Mistral-7B-GGUF" + ), + LocalModelInfo( + id = "openchat_7b", displayName = "OpenChat 3.5 7B (Q4)", + description = "OpenChat 3.5 ~4.1GB. Apache 2.0 license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/OpenChat-3.5-0106-GGUF", "openchat-3.5-0106.Q4_K_M.gguf"), + fileSizeBytes = 4_100_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "openchat"), huggingFaceRepo = "bartowski/OpenChat-3.5-0106-GGUF" + ), + LocalModelInfo( + id = "dolphin_llama3_8b", displayName = "Dolphin 2.9 Llama 3 8B (Q4)", + description = "Dolphin 2.9 ~4.5GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/dolphin-2.9-llama3-8b-GGUF", "dolphin-2.9-llama3-8b.Q4_K_M.gguf"), + fileSizeBytes = 4_500_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "dolphin"), huggingFaceRepo = "bartowski/dolphin-2.9-llama3-8b-GGUF" + ), + LocalModelInfo( + id = "qwen2.5_7b", displayName = "Qwen 2.5 7B (Q4)", + description = "Qwen 2.5 7B ~4.4GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenGGUF("7"), fileSizeBytes = 4_400_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "qwen"), huggingFaceRepo = "Qwen/Qwen2.5-7B-Instruct-GGUF" + ), + LocalModelInfo( + id = "deepseek_r1_distill_7b", displayName = "DeepSeek R1 Distill 7B (Q4)", + description = "DeepSeek R1 reasoning distilled to Qwen 7B ~4.4GB. MIT.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF", "deepseek-r1-distill-qwen-7b.Q4_K_M.gguf"), + fileSizeBytes = 4_400_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "deepseek", "reasoning"), huggingFaceRepo = "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF" + ), + LocalModelInfo( + id = "deepseek_coder_6.7b", displayName = "DeepSeek Coder 6.7B (Q4)", + description = "DeepSeek Coder 6.7B ~3.9GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/DeepSeek-Coder-6.7B-Instruct-GGUF", "deepseek-coder-6.7b-instruct.Q4_K_M.gguf"), + fileSizeBytes = 3_900_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "deepseek"), huggingFaceRepo = "bartowski/DeepSeek-Coder-6.7B-Instruct-GGUF" + ), + LocalModelInfo( + id = "qwen2.5_coder_7b", displayName = "Qwen 2.5 Coder 7B (Q4)", + description = "Qwen code model ~4.4GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenCoderGGUF("7"), fileSizeBytes = 4_400_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "qwen"), huggingFaceRepo = "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF" + ), + LocalModelInfo( + id = "yi_1.5_6b", displayName = "Yi 1.5 6B (Q4)", + description = "Yi 1.5 6B Chat ~3.5GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Yi-1.5-6B-Chat-GGUF", "yi-1.5-6b-chat.Q4_K_M.gguf"), + fileSizeBytes = 3_500_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "yi"), huggingFaceRepo = "bartowski/Yi-1.5-6B-Chat-GGUF" + ), + LocalModelInfo( + id = "yi_1.5_9b", displayName = "Yi 1.5 9B (Q4)", + description = "Yi 1.5 9B Chat ~5.2GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Yi-1.5-9B-Chat-GGUF", "yi-1.5-9b-chat.Q4_K_M.gguf"), + fileSizeBytes = 5_200_000_000, ramRequiredMb = 4096, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "yi"), huggingFaceRepo = "bartowski/Yi-1.5-9B-Chat-GGUF" + ), + LocalModelInfo( + id = "falcon2_11b", displayName = "Falcon 2 11B (Q4)", + description = "TII Falcon 2 11B ~6.1GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Falcon2-11B-GGUF", "falcon2-11b.Q4_K_M.gguf"), + fileSizeBytes = 6_100_000_000, ramRequiredMb = 4096, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "falcon"), huggingFaceRepo = "bartowski/Falcon2-11B-GGUF" + ), + LocalModelInfo( + id = "stablelm2_12b", displayName = "StableLM 2 12B (Q4)", + description = "Stability AI's 12B model ~7GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/stablelm-2-12b-chat-GGUF", "stablelm-2-12b-chat.Q4_K_M.gguf"), + fileSizeBytes = 7_000_000_000, ramRequiredMb = 4096, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "stable", "stability"), huggingFaceRepo = "bartowski/stablelm-2-12b-chat-GGUF" + ), + LocalModelInfo( + id = "starcoder2_7b", displayName = "StarCoder2 7B (Q4)", + description = "Code gen 7B ~4.1GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/StarCoder2-7B-GGUF", "starcoder2-7b.Q4_K_M.gguf"), + fileSizeBytes = 4_100_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "starcoder"), huggingFaceRepo = "bartowski/StarCoder2-7B-GGUF" + ), + LocalModelInfo( + id = "phi3_small_7b", displayName = "Phi-3 Small 7B (Q4)", + description = "Microsoft Phi-3 Small 7B ~4.2GB. MIT via bartowski.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Phi-3-small-8k-instruct-GGUF", "phi-3-small-8k-instruct.Q4_K_M.gguf"), + fileSizeBytes = 4_200_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "phi3"), huggingFaceRepo = "bartowski/Phi-3-small-8k-instruct-GGUF" + ), + LocalModelInfo( + id = "gemma_1.1_7b_q4", displayName = "Gemma 1.1 7B (Q4)", + description = "Google Gemma 1.1 7B ~4.3GB.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Gemma-1.1-7B-it-GGUF", "gemma-1.1-7b-it.Q4_K_M.gguf"), + fileSizeBytes = 4_300_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "gemma"), huggingFaceRepo = "bartowski/Gemma-1.1-7B-it-GGUF" + ), + LocalModelInfo( + id = "granite3_8b", displayName = "Granite 3.0 8B (Q4)", + description = "IBM Granite 3.0 8B ~4.5GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Granite-3.0-8B-Instruct-GGUF", "granite-3.0-8b-instruct.Q4_K_M.gguf"), + fileSizeBytes = 4_500_000_000, ramRequiredMb = 3072, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "granite", "ibm"), huggingFaceRepo = "bartowski/Granite-3.0-8B-Instruct-GGUF" + ), + LocalModelInfo( + id = "mistral_nemo_12b", displayName = "Mistral Nemo 12B (Q4)", + description = "Mistral AI & NVIDIA Nemo 12B ~7GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Mistral-Nemo-Instruct-2407-GGUF", "mistral-nemo-instruct-2407.Q4_K_M.gguf"), + fileSizeBytes = 7_000_000_000, ramRequiredMb = 4096, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "mistral", "nemo"), huggingFaceRepo = "bartowski/Mistral-Nemo-Instruct-2407-GGUF" + ), + + // ====================================================================== + // HIGH END DEVICES (4GB - 8GB RAM) + // ====================================================================== + + LocalModelInfo( + id = "bge_large", displayName = "BGE Large Embeddings", + description = "High quality embeddings ~560MB.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("BAAI/bge-large-en-v1.5", "onnx/model.onnx"), + fileSizeBytes = 560_000_000, ramRequiredMb = 1024, + type = ModelType.EMBEDDING, format = ModelFormat.ONNX, + tags = listOf("embedding", "bge", "large", "quality"), huggingFaceRepo = "BAAI/bge-large-en-v1.5" + ), + LocalModelInfo( + id = "qwen2.5_14b", displayName = "Qwen 2.5 14B (Q4)", + description = "Qwen 2.5 14B ~8.5GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenGGUF("14"), fileSizeBytes = 8_500_000_000, ramRequiredMb = 6144, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, isRecommended = true, + tags = listOf("chat", "qwen", "quality"), huggingFaceRepo = "Qwen/Qwen2.5-14B-Instruct-GGUF" + ), + LocalModelInfo( + id = "qwen2.5_coder_14b", displayName = "Qwen 2.5 Coder 14B (Q4)", + description = "Qwen code model 14B ~8.5GB. Apache 2.0.", + source = ModelSource.HUGGINGFACE, + downloadUrl = qwenCoderGGUF("14"), fileSizeBytes = 8_500_000_000, ramRequiredMb = 6144, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "qwen", "large"), huggingFaceRepo = "Qwen/Qwen2.5-Coder-14B-Instruct-GGUF" + ), + LocalModelInfo( + id = "nous_solar_10.7b", displayName = "Nous Hermes 2 SOLAR 10.7B (Q4)", + description = "SOLAR 10.7B finetune ~6.1GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/Nous-Hermes-2-SOLAR-10.7B-GGUF", "nous-hermes-2-solar-10.7b.Q4_K_M.gguf"), + fileSizeBytes = 6_100_000_000, ramRequiredMb = 6144, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "nous"), huggingFaceRepo = "bartowski/Nous-Hermes-2-SOLAR-10.7B-GGUF" + ), + LocalModelInfo( + id = "deepseek_v2_lite", displayName = "DeepSeek V2 Lite (Q4)", + description = "DeepSeek V2 Lite 16B ~12GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/DeepSeek-V2-Lite-Chat-GGUF", "deepseek-v2-lite-chat.Q4_K_M.gguf"), + fileSizeBytes = 12_000_000_000, ramRequiredMb = 6144, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("chat", "deepseek", "v2"), huggingFaceRepo = "bartowski/DeepSeek-V2-Lite-Chat-GGUF" + ), + LocalModelInfo( + id = "starcoder2_15b", displayName = "StarCoder2 15B (Q4)", + description = "Code gen 15B ~8.7GB. MIT license.", + source = ModelSource.HUGGINGFACE, + downloadUrl = hfBlake("bartowski/StarCoder2-15B-GGUF", "starcoder2-15b.Q4_K_M.gguf"), + fileSizeBytes = 8_700_000_000, ramRequiredMb = 6144, + type = ModelType.TEXT_GENERATION, format = ModelFormat.GGUF, + tags = listOf("code", "starcoder", "large"), huggingFaceRepo = "bartowski/StarCoder2-15B-GGUF" + ), + + // ====================================================================== + // ONNX / TFLITE (Cross-platform) + // ====================================================================== + + LocalModelInfo( + id = "allminilm_onnx", displayName = "MiniLM ONNX", + description = "Cross-platform embedding ~90MB. Works on any device.", + source = ModelSource.ONNX, + downloadUrl = hfBlake("sentence-transformers/all-MiniLM-L6-v2", "onnx/model.onnx"), + fileSizeBytes = 90_000_000, ramRequiredMb = 512, + type = ModelType.EMBEDDING, format = ModelFormat.ONNX, + tags = listOf("embedding", "onnx", "cross-platform"), huggingFaceRepo = "sentence-transformers/all-MiniLM-L6-v2" + ), + + // ====================================================================== + // USER IMPORT + // ====================================================================== + + LocalModelInfo( + id = "user_imported", displayName = "Import Custom Model", + description = "Import your own .onnx, .tflite, or .gguf model file.", + source = ModelSource.USER_IMPORTED, downloadUrl = "", fileSizeBytes = 0, ramRequiredMb = 0, + type = ModelType.TEXT_GENERATION, format = ModelFormat.BIN, tags = listOf("custom", "import") + ) + ) + + fun forCurrentDevice(): List { + val tier = deviceTier() + return all.filter { it.ramRequiredMb <= tier.maxRamMb } + } + + fun recommended(): List = forCurrentDevice().filter { it.isRecommended } + fun embeddingModels(): List = all.filter { it.type == ModelType.EMBEDDING } + fun textModels(): List = all.filter { it.type == ModelType.TEXT_GENERATION } + fun downloadable(): List = all.filter { it.source != ModelSource.USER_IMPORTED && it.downloadUrl.isNotBlank() } + fun byId(id: String): LocalModelInfo? = all.find { it.id == id } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/local/LocalModelManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/local/LocalModelManager.kt new file mode 100644 index 000000000..fb237de22 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/local/LocalModelManager.kt @@ -0,0 +1,373 @@ +package com.theveloper.pixelplay.data.ai.local + +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL +import java.net.UnknownHostException +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException + +class HttpDownloadException(val responseCode: Int, message: String) : IOException(message) + +private fun sanitizeModelId(id: String): String { + val clean = id.filter { it.isLetterOrDigit() || it == '_' || it == '-' || it == '.' } + return clean.ifEmpty { "unnamed" } +} + +private const val TAG = "LocalModelManager" +private const val MODELS_DIR = "local_ai_models" +private const val TIMEOUT_CONNECT = 15_000 +private const val TIMEOUT_READ = 60_000 +private const val BUFFER_SIZE = 65536 +private const val MAX_RETRIES = 3 +private const val GGUF_MAGIC_0: Byte = 'G'.code.toByte() +private const val GGUF_MAGIC_1: Byte = 'G'.code.toByte() + +@Singleton +class LocalModelManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val modelsDir: File + get() = File(context.filesDir, MODELS_DIR).also { it.mkdirs() } + + private val _statusMap = MutableStateFlow>(emptyMap()) + val statusMap: StateFlow> = _statusMap.asStateFlow() + + private val _activeModelId = MutableStateFlow(null) + val activeModelId: StateFlow = _activeModelId.asStateFlow() + + private val downloadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val activeDownloads = mutableMapOf() + private val activeConnections = mutableMapOf() + + // ======== Query Operations ======== + + fun getInstalledModels(): List = modelsDir.listFiles() + ?.filter { it.isFile && it.length() > 1000 } + ?.sortedByDescending { it.lastModified() } + ?: emptyList() + + fun isInstalled(modelId: String): Boolean = modelFile(modelId).exists() + + fun modelFile(modelId: String): File = File(modelsDir, sanitizeModelId(modelId)) + + fun getModelStatus(modelId: String): ModelStatus = _statusMap.value[modelId] + ?: if (isInstalled(modelId)) ModelStatus.Ready else ModelStatus.NotDownloaded + + fun getModelSize(modelId: String): Long = modelFile(modelId).let { if (it.exists()) it.length() else 0 } + + suspend fun validateModelFile(modelId: String): ValidationResult = withContext(Dispatchers.IO) { + val file = modelFile(modelId) + if (!file.exists()) return@withContext ValidationResult.Missing + val info = LocalModelCatalog.byId(modelId) + if (info != null && info.fileSizeBytes > 0) { + val sizeOk = file.length() in (info.fileSizeBytes * 0.8).toLong()..(info.fileSizeBytes * 1.2).toLong() + if (!sizeOk) return@withContext ValidationResult.SizeMismatch(file.length(), info.fileSizeBytes) + } + if (file.name.endsWith(".gguf")) { + val bytes = file.inputStream().use { it.readNBytes(4) } + if (bytes.size < 4 || bytes[0] != GGUF_MAGIC_0 || bytes[1] != GGUF_MAGIC_1) { + return@withContext ValidationResult.Corrupted("Invalid GGUF magic bytes") + } + } + ValidationResult.Ok + } + + sealed class ValidationResult { + object Ok : ValidationResult() + object Missing : ValidationResult() + data class SizeMismatch(val actual: Long, val expected: Long) : ValidationResult() + data class Corrupted(val detail: String) : ValidationResult() + } + + // ======== Download Operations ======== + + fun downloadModel(info: LocalModelInfo) { + if (info.downloadUrl.isBlank()) { + _statusMap.update { it + (info.id to ModelStatus.Error("No download URL available")) } + return + } + if (modelFile(info.id).exists()) { + _statusMap.update { it + (info.id to ModelStatus.Ready) } + return + } + if (activeDownloads[info.id]?.isActive == true) return + + val job = downloadScope.launch { + downloadWithRetry(info) + } + activeDownloads[info.id] = job + } + + private suspend fun downloadWithRetry(info: LocalModelInfo, attempt: Int = 1) { + _statusMap.update { it + (info.id to ModelStatus.Downloading(0, 0, info.fileSizeBytes)) } + try { + performDownload(info) + activeDownloads.remove(info.id) + activeConnections.remove(info.id) + } catch (e: CancellationException) { + Timber.i("Download cancelled: ${info.id}") + cleanupTmp(info.id) + _statusMap.update { it + (info.id to ModelStatus.NotDownloaded) } + activeDownloads.remove(info.id) + activeConnections.remove(info.id) + throw e + } catch (e: Exception) { + activeConnections.remove(info.id) + val retryable = isRetryable(e) + if (retryable && attempt < MAX_RETRIES) { + val delayMs = (1L shl (attempt + 1)) * 1000L + Timber.w("Download attempt $attempt failed for ${info.id}, retrying in ${delayMs}ms: ${e.message}") + _statusMap.update { + it + (info.id to ModelStatus.Downloading( + 0, getTmpSize(info.id), info.fileSizeBytes, 0 + )) + } + delay(delayMs) + downloadWithRetry(info, attempt + 1) + } else { + Timber.e(e, "Download failed after $attempt attempts: ${info.id}") + _statusMap.update { + it + (info.id to ModelStatus.Error(classifyError(e, attempt))) + } + activeDownloads.remove(info.id) + } + } + } + + private suspend fun performDownload(info: LocalModelInfo) { + val file = modelFile(info.id) + val tmp = File(modelsDir, "${info.id}.tmp") + var resumeFrom = if (tmp.exists()) tmp.length() else 0L + var downloaded = resumeFrom + var currentSpeed = 0L + var lastSpeedTime = System.nanoTime() + var lastSpeedBytes = resumeFrom + var usedDownloadParam = false + + while (true) { + val downloadUrl = if (usedDownloadParam) "${info.downloadUrl}?download=1" else info.downloadUrl + val conn = URL(downloadUrl).openConnection() as HttpURLConnection + activeConnections[info.id] = conn + conn.connectTimeout = TIMEOUT_CONNECT + conn.readTimeout = TIMEOUT_READ + conn.setRequestProperty("User-Agent", "PixelPlayer/1.0") + conn.instanceFollowRedirects = true + if (resumeFrom > 0) conn.setRequestProperty("Range", "bytes=$resumeFrom-") + conn.connect() + + val responseCode = try { + conn.responseCode + } catch (e: IOException) { + conn.disconnect() + throw e + } + + if (responseCode !in 200..299) { + conn.disconnect() + if (!usedDownloadParam && info.huggingFaceRepo != null) { + usedDownloadParam = true + cleanupTmp(info.id) + downloaded = 0L + resumeFrom = 0L + lastSpeedBytes = 0L + Timber.d("Retrying ${info.id} with ?download=1") + continue + } + throw HttpDownloadException(responseCode, conn.responseMessage ?: "HTTP $responseCode") + } + + val total = conn.contentLengthLong.let { if (it <= 0) -1L else it + resumeFrom } + val actualTotal = if (total > 0) total else info.fileSizeBytes + + try { + conn.inputStream.use { input -> + FileOutputStream(tmp, resumeFrom > 0).use { output -> + val buf = ByteArray(BUFFER_SIZE) + var read: Int + while (input.read(buf).also { read = it } != -1) { + yield() + output.write(buf, 0, read) + downloaded += read + val now = System.nanoTime() + val dt = now - lastSpeedTime + if (dt >= 1_000_000_000L) { + val elapsedSec = dt / 1_000_000_000.0 + currentSpeed = ((downloaded - lastSpeedBytes) / elapsedSec).toLong() + lastSpeedTime = now + lastSpeedBytes = downloaded + } + val progress = if (actualTotal > 0) ((downloaded * 100) / actualTotal).toInt().coerceIn(0, 100) else 0 + _statusMap.update { + it + (info.id to ModelStatus.Downloading(progress, downloaded, actualTotal, currentSpeed)) + } + } + } + } + } catch (e: FileNotFoundException) { + conn.disconnect() + if (!usedDownloadParam && info.huggingFaceRepo != null) { + usedDownloadParam = true + cleanupTmp(info.id) + downloaded = 0L + resumeFrom = 0L + lastSpeedBytes = 0L + Timber.d("Retrying ${info.id} with ?download=1 after FileNotFoundException") + continue + } + throw HttpDownloadException(404, e.message ?: "File not found") + } + + if (!tmp.renameTo(file)) { + file.delete() + tmp.copyTo(file, overwrite = true) + tmp.delete() + } + _statusMap.update { it + (info.id to ModelStatus.Ready) } + Timber.i("Downloaded model: ${info.id} (${downloaded / (1024 * 1024)} MB)") + return + } + } + + suspend fun downloadAndWait(info: LocalModelInfo): ModelStatus { + downloadModel(info) + while (true) { + val status = getModelStatus(info.id) + if (status !is ModelStatus.Downloading && status !is ModelStatus.Pending) return status + delay(500) + } + } + + fun cancelDownload(modelId: String) { + activeConnections[modelId]?.disconnect() + activeDownloads[modelId]?.cancel() + } + + // ======== Import Operations ======== + + suspend fun importModel(uri: Uri, modelId: String): Result = withContext(Dispatchers.IO) { + try { + val file = modelFile(modelId) + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } ?: throw Exception("Cannot open URI") + _statusMap.update { it + (modelId to ModelStatus.Ready) } + Result.success(file) + } catch (e: Exception) { + Timber.e(e, "Import failed") + Result.failure(e) + } + } + + // ======== Delete Operations ======== + + suspend fun deleteModel(modelId: String): Boolean = withContext(Dispatchers.IO) { + cancelDownload(modelId) + val file = modelFile(modelId) + val deleted = file.delete() + cleanupTmp(modelId) + if (deleted) { + _statusMap.update { it - modelId } + if (_activeModelId.value == modelId) _activeModelId.value = null + } + deleted + } + + // ======== Model Selection ======== + + fun seedStatus(modelId: String, status: ModelStatus) { + _statusMap.update { it + (modelId to status) } + } + + fun setActiveModel(modelId: String?) { + _activeModelId.value = modelId + } + + // ======== Cleanup ======== + + fun cleanupScope() { + downloadScope.cancel() + } + + // ======== Inference (placeholder – engine integration pending) ======== + + suspend fun runInference(modelId: String, prompt: String): String? = withContext(Dispatchers.IO) { + val file = modelFile(modelId) + if (!file.exists()) { Timber.w("Model not installed: $modelId"); return@withContext null } + val validation = validateModelFile(modelId) + if (validation !is ValidationResult.Ok) { + Timber.w("Model validation failed for $modelId: $validation") + return@withContext null + } + Timber.d("Inference requested: $modelId (${file.length() / (1024 * 1024)} MB)") + null + } + + // ======== Private Helpers ======== + + private fun cleanupTmp(modelId: String) { + File(modelsDir, "${sanitizeModelId(modelId)}.tmp").delete() + } + + private fun getTmpSize(modelId: String): Long { + val tmp = File(modelsDir, "${sanitizeModelId(modelId)}.tmp") + return if (tmp.exists()) tmp.length() else 0L + } + + private fun isRetryable(e: Exception): Boolean = when (e) { + is SocketTimeoutException, is UnknownHostException -> true + is HttpDownloadException -> e.responseCode in 429..599 + is IOException -> e.message?.contains("timed out", ignoreCase = true) == true + || e.message?.contains("reset", ignoreCase = true) == true + || e.message?.contains("refused", ignoreCase = true) == true + else -> false + } + + private fun classifyError(e: Exception, attempt: Int): String = when (e) { + is SocketTimeoutException -> "Connection timed out. Check your network." + is UnknownHostException -> "Cannot reach server. Check your internet connection." + is HttpDownloadException -> when (e.responseCode) { + 404 -> "Model file not found (404). The download URL may be outdated." + 403 -> "Access denied (403). The model may require authentication." + 401 -> "Authentication required (401)." + 429 -> "Rate limited (429). Please try again later." + in 500..599 -> "Server error (${e.responseCode}). Try again later." + else -> "HTTP error ${e.responseCode}: ${e.message}" + } + is IOException -> { + when { + e.message?.contains("Unable to resolve host") == true -> "DNS resolution failed. Check network." + e.message?.contains("Permission denied") == true -> "Storage permission required." + e.message?.contains("No space") == true -> "Not enough storage space." + else -> "Network error: ${e.message ?: "Unknown"}" + } + } + else -> if (attempt >= MAX_RETRIES) "Download failed after $attempt attempts: ${e.message ?: "Unknown error"}" + else e.message ?: "Download failed" + } +} 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..b23f05431 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 @@ -13,48 +13,27 @@ class AiClientFactory @Inject constructor() { * Create an AI client for the specified provider * @param provider The AI provider type * @param apiKey The API key for the provider + * @param customEndpoint Optional custom endpoint override * @return AiClient instance */ - fun createClient(provider: AiProvider, apiKey: String): AiClient { - if (apiKey.isBlank()) { + fun createClient(provider: AiProvider, apiKey: String, customEndpoint: String = ""): AiClient { + if (apiKey.isBlank() && provider.requiresApiKey) { throw IllegalArgumentException("API Key cannot be blank for ${provider.displayName}") } - + return when (provider) { AiProvider.GEMINI -> GeminiAiClient(apiKey) - AiProvider.DEEPSEEK -> DeepSeekAiClient(apiKey) - AiProvider.GROQ -> GroqAiClient(apiKey) - AiProvider.MISTRAL -> MistralAiClient(apiKey) - AiProvider.NVIDIA -> GenericOpenAiClient( - apiKey = apiKey, - baseUrl = "https://integrate.api.nvidia.com/v1", - defaultModelId = "meta/llama-3.1-8b-instruct", - providerName = "NVIDIA NIM" - ) - AiProvider.KIMI -> GenericOpenAiClient( - apiKey = apiKey, - baseUrl = "https://api.moonshot.cn/v1", - defaultModelId = "moonshot-v1-8k", - providerName = "Moonshot Kimi" - ) - AiProvider.GLM -> GenericOpenAiClient( - apiKey = apiKey, - baseUrl = "https://open.bigmodel.cn/api/paas/v4", - defaultModelId = "glm-4", - providerName = "Zhipu GLM" - ) - AiProvider.OPENAI -> GenericOpenAiClient( - apiKey = apiKey, - baseUrl = "https://api.openai.com/v1", - defaultModelId = "gpt-4o-mini", - providerName = "OpenAI" - ) - AiProvider.OPENROUTER -> GenericOpenAiClient( - apiKey = apiKey, - baseUrl = "https://openrouter.ai/api/v1", - defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free", - providerName = "OpenRouter" - ) + AiProvider.DEEPSEEK -> GenericOpenAiClient(apiKey, AiProviderEndpoints.DEEPSEEK_BASE_URL, AiProviderEndpoints.DEEPSEEK_DEFAULT_MODEL, "DeepSeek") + AiProvider.GROQ -> GenericOpenAiClient(apiKey, AiProviderEndpoints.GROQ_BASE_URL, AiProviderEndpoints.GROQ_DEFAULT_MODEL, "Groq") + AiProvider.MISTRAL -> GenericOpenAiClient(apiKey, AiProviderEndpoints.MISTRAL_BASE_URL, AiProviderEndpoints.MISTRAL_DEFAULT_MODEL, "Mistral") + AiProvider.NVIDIA -> GenericOpenAiClient(apiKey, AiProviderEndpoints.NVIDIA_BASE_URL, AiProviderEndpoints.NVIDIA_DEFAULT_MODEL, "NVIDIA NIM") + AiProvider.KIMI -> GenericOpenAiClient(apiKey, AiProviderEndpoints.KIMI_BASE_URL, AiProviderEndpoints.KIMI_DEFAULT_MODEL, "Moonshot Kimi") + AiProvider.GLM -> GenericOpenAiClient(apiKey, AiProviderEndpoints.GLM_BASE_URL, AiProviderEndpoints.GLM_DEFAULT_MODEL, "Zhipu GLM") + AiProvider.OPENAI -> GenericOpenAiClient(apiKey, AiProviderEndpoints.OPENAI_BASE_URL, AiProviderEndpoints.OPENAI_DEFAULT_MODEL, "OpenAI") + AiProvider.OPENROUTER -> GenericOpenAiClient(apiKey, AiProviderEndpoints.OPENROUTER_BASE_URL, AiProviderEndpoints.OPENROUTER_DEFAULT_MODEL, "OpenRouter") + AiProvider.ANTHROPIC -> AnthropicAiClient(apiKey) + AiProvider.OLLAMA -> GenericOpenAiClient(apiKey, customEndpoint.ifBlank { AiProviderEndpoints.OLLAMA_BASE_URL }, AiProviderEndpoints.OLLAMA_DEFAULT_MODEL, "Ollama") + AiProvider.LOCAL -> throw IllegalArgumentException("LOCAL provider does not use AiClient - use LocalModelManager for on-device inference") } } } 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..cc9bdd274 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt @@ -1,22 +1,108 @@ package com.theveloper.pixelplay.data.ai.provider -/** - * Enum representing available AI providers - */ -enum class AiProvider(val displayName: String, val requiresApiKey: Boolean) { - GEMINI("Google Gemini", requiresApiKey = true), - DEEPSEEK("DeepSeek", requiresApiKey = true), - GROQ("Groq", requiresApiKey = true), - MISTRAL("Mistral", requiresApiKey = true), - NVIDIA("NVIDIA NIM", requiresApiKey = true), - KIMI("Kimi (Moonshot)", requiresApiKey = true), - GLM("Zhipu GLM", requiresApiKey = true), - OPENAI("OpenAI", requiresApiKey = true), - OPENROUTER("OpenRouter", requiresApiKey = true); - +enum class AiProvider( + val displayName: String, + val requiresApiKey: Boolean, + val supportsCustomEndpoint: Boolean = false, + val defaultEndpoint: String = "", + val models: List = emptyList() +) { + GEMINI( + "Google Gemini", requiresApiKey = true, + models = listOf( + "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-2.0-flash-lite", + "gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b", + "gemma-3-27b-it", "gemma-3-12b-it" + ) + ), + DEEPSEEK( + "DeepSeek", requiresApiKey = true, + models = listOf("deepseek-chat", "deepseek-reasoner", "deepseek-coder", "deepseek-v3") + ), + GROQ( + "Groq", requiresApiKey = true, + models = listOf( + "llama-3.3-70b-versatile", "llama-3.2-90b-vision-preview", "llama-3.1-8b-instant", + "llama-3.1-70b-versatile", "mixtral-8x7b-32768", "gemma2-9b-it", + "llama-guard-3-8b", "deepseek-r1-distill-llama-70b" + ) + ), + MISTRAL( + "Mistral", requiresApiKey = true, + models = listOf( + "mistral-large-latest", "mistral-small-latest", "mistral-medium-latest", + "open-mistral-nemo", "open-codestral-mamba", "codestral-latest", + "ministral-8b-latest", "ministral-3b-latest", "pixtral-12b-2409" + ) + ), + NVIDIA( + "NVIDIA NIM", requiresApiKey = true, + models = listOf( + "meta/llama-3.1-8b-instruct", "meta/llama-3.1-70b-instruct", "meta/llama-3.1-405b-instruct", + "mistralai/mistral-7b-instruct-v0.3", "mistralai/mixtral-8x22b-instruct-v0.1", + "google/gemma-2-2b-it", "google/gemma-2-9b-it", "google/gemma-2-27b-it", + "nvidia/llama-3.1-nemotron-70b-instruct-hf", "microsoft/phi-3-medium-14b-instruct" + ) + ), + KIMI( + "Kimi (Moonshot)", requiresApiKey = true, + models = listOf( + "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k", + "moonshot-v1-auto" + ) + ), + GLM( + "Zhipu GLM", requiresApiKey = true, + models = listOf( + "glm-4", "glm-4v", "glm-4-plus", "glm-4-air", + "glm-4-airx", "glm-4-long", "glm-4-flash" + ) + ), + OPENAI( + "OpenAI", requiresApiKey = true, + models = listOf( + "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo", + "o1", "o1-mini", "o1-preview", "o3-mini", + "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", + "gpt-4.5-preview" + ) + ), + OPENROUTER( + "OpenRouter", requiresApiKey = true, supportsCustomEndpoint = true, + defaultEndpoint = "https://openrouter.ai/api/v1", + models = listOf( + "google/gemini-2.5-flash:free", "google/gemini-2.0-flash-lite-preview-02-05:free", + "anthropic/claude-3.5-sonnet", "anthropic/claude-3-haiku", + "openai/gpt-4o-mini", "openai/o3-mini", + "mistralai/mistral-small", "mistralai/mistral-large", + "meta-llama/llama-3.3-70b-instruct", "meta-llama/llama-3.1-8b-instruct:free", + "deepseek/deepseek-chat", "qwen/qwen-2.5-72b-instruct", + "cohere/command-r-plus", "google/gemma-2-9b-it:free", + "microsoft/phi-3-medium-14b-instruct:free" + ) + ), + ANTHROPIC( + "Anthropic Claude", requiresApiKey = true, + models = listOf( + "claude-sonnet-4-20250514", "claude-4-opus-20250506", + "claude-3-5-sonnet-20241022", "claude-3-opus-20240229", + "claude-3-haiku-20240307", "claude-3-5-haiku-20241022" + ) + ), + OLLAMA( + "Ollama Server", requiresApiKey = true, supportsCustomEndpoint = true, + defaultEndpoint = "https://ollama.ai/api", + models = listOf( + "llama3", "llama3.1", "llama3.2", "llama3.3", + "mistral", "mixtral", "gemma2", "phi3", "phi4", + "tinyllama", "llama2", "codellama", "neural-chat", + "starling-lm", "qwen2.5", "deepseek-coder", "command-r", + "dolphin-mixtral", "yi", "falcon2", "starcoder2" + ) + ), + LOCAL("Local Model (Device)", requiresApiKey = false); + companion object { - fun fromString(value: String): AiProvider { - return entries.find { it.name == value } ?: GEMINI - } + fun fromString(value: String): AiProvider = entries.find { it.name == value } ?: GEMINI } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderEndpoints.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderEndpoints.kt new file mode 100644 index 000000000..843dd91b8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderEndpoints.kt @@ -0,0 +1,37 @@ +package com.theveloper.pixelplay.data.ai.provider + +/** + * Centralized configuration for all AI provider API endpoints and default models. + * Every URL, model ID, and provider-specific header value lives here. + * Nothing in any AiClient or factory should ever hardcode these strings directly. + */ +object AiProviderEndpoints { + + const val GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + const val DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" + const val GROQ_BASE_URL = "https://api.groq.com/openai/v1" + const val MISTRAL_BASE_URL = "https://api.mistral.ai/v1" + const val NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1" + const val KIMI_BASE_URL = "https://api.moonshot.cn/v1" + const val GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4" + const val OPENAI_BASE_URL = "https://api.openai.com/v1" + const val OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + const val ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1" + const val OLLAMA_BASE_URL = "https://ollama.ai/api/v1" + + const val GEMINI_DEFAULT_MODEL = "gemini-2.5-flash" + const val DEEPSEEK_DEFAULT_MODEL = "deepseek-chat" + const val GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile" + const val MISTRAL_DEFAULT_MODEL = "mistral-small-latest" + const val NVIDIA_DEFAULT_MODEL = "meta/llama-3.1-8b-instruct" + const val KIMI_DEFAULT_MODEL = "moonshot-v1-8k" + const val GLM_DEFAULT_MODEL = "glm-4-flash" + const val OPENAI_DEFAULT_MODEL = "gpt-4o-mini" + const val OPENROUTER_DEFAULT_MODEL = "google/gemini-2.5-flash:free" + const val ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-20250514" + const val OLLAMA_DEFAULT_MODEL = "llama3.2" + + const val ANTHROPIC_API_VERSION = "2023-06-01" + const val OPENROUTER_SITE_URL = "https://github.com/theovilardo/PixelPlayer" + const val OPENROUTER_SITE_NAME = "PixelPlayer" +} 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..e8d70c78e 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 @@ -5,211 +5,67 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -internal class AiProviderException( - val providerName: String, - val statusCode: Int? = null, - val requestedModel: String? = null, - val providerCode: String? = null, - val providerType: String? = null, - val rawBody: String? = null, - message: String, - cause: Throwable? = null +class AiProviderException( + val providerName: String, val statusCode: Int? = null, val requestedModel: String? = null, + val providerCode: String? = null, val providerType: String? = null, val rawBody: String? = null, + message: String, cause: Throwable? = null ) : Exception(message, cause) { - fun isModelUnavailable(): Boolean { - val text = buildSearchText() - val mentionsMissingModel = text.contains("model") && - ( - text.contains("not found") || - text.contains("does not exist") || - text.contains("unknown model") || - text.contains("unsupported model") || - text.contains("invalid model") || - text.contains("model_not_found") - ) + private val text get() = listOfNotNull(message, rawBody, providerCode, providerType).joinToString(" ").lowercase() - return statusCode == 404 || mentionsMissingModel - } - - fun isBillingIssue(): Boolean { - val text = buildSearchText() - return statusCode == 402 || - text.contains("insufficient_quota") || - text.contains("quota") || - text.contains("credit") || - text.contains("credits") || - text.contains("billing") || - text.contains("payment required") || - text.contains("balance") - } + fun isModelUnavailable() = statusCode == 404 || + (text.contains("model") && listOf("not found", "does not exist", "unknown model", "unsupported model", "invalid model", "model_not_found").any { text.contains(it) }) - fun isApiKeyIssue(): Boolean { - val text = buildSearchText() - return statusCode == 401 || - text.contains("api_key_invalid") || - text.contains("api key not valid") || - text.contains("invalid api key") || - text.contains("invalid key") || - text.contains("incorrect api key") || - text.contains("authentication failed") || - text.contains("unauthorized") - } + fun isBillingIssue() = statusCode == 402 || listOf("insufficient_quota", "quota", "credit", "credits", "billing", "payment required", "balance").any { text.contains(it) } - fun shouldCooldown(): Boolean { - val text = buildSearchText() - return isBillingIssue() || - isApiKeyIssue() || - (statusCode != null && statusCode >= 500) || - text.contains("timeout") || - text.contains("timed out") || - text.contains("unable to resolve host") || - text.contains("failed to connect") || - text.contains("connection reset") || - text.contains("network") - } + fun isApiKeyIssue() = statusCode == 401 || listOf("api_key_invalid", "api key not valid", "invalid api key", "invalid key", "incorrect api key", "authentication failed", "unauthorized").any { text.contains(it) } - private fun buildSearchText(): String { - return listOfNotNull(message, rawBody, providerCode, providerType) - .joinToString(" ") - .lowercase() - } + fun shouldCooldown() = isBillingIssue() || isApiKeyIssue() || + (statusCode != null && statusCode >= 500) || + listOf("timeout", "timed out", "unable to resolve host", "failed to connect", "connection reset", "network").any { text.contains(it) } } -internal object AiProviderSupport { +object AiProviderSupport { private val json = Json { ignoreUnknownKeys = true } - fun buildProviderChain(primary: AiProvider): List { - val preferredFallbacks = listOf( - AiProvider.GROQ, - AiProvider.GEMINI, - AiProvider.DEEPSEEK, - AiProvider.MISTRAL, - AiProvider.OPENAI, - AiProvider.OPENROUTER, - AiProvider.NVIDIA, - AiProvider.KIMI, - AiProvider.GLM - ) - - return buildList { - add(primary) - addAll(preferredFallbacks.filter { it != primary }) - addAll(AiProvider.entries.filter { it != primary && it !in preferredFallbacks }) - }.distinct() + fun buildProviderChain(primary: AiProvider) = buildList { + add(primary) + addAll(AiProvider.entries.filter { it != primary }) } - fun selectRecoveryModel( - currentModel: String, - defaultModel: String, - availableModels: List - ): String? { - val normalizedCurrent = currentModel.trim() - val normalizedDefault = defaultModel.trim() - val normalizedAvailable = availableModels - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - - if (normalizedAvailable.isNotEmpty()) { - val preferredDefault = normalizedAvailable.firstOrNull { it == normalizedDefault } - if (preferredDefault != null && preferredDefault != normalizedCurrent) { - return preferredDefault - } - - val firstAlternative = normalizedAvailable.firstOrNull { it != normalizedCurrent } - if (firstAlternative != null) { - return firstAlternative - } + fun selectRecoveryModel(currentModel: String, defaultModel: String, availableModels: List): String? { + val avail = availableModels.map { it.trim() }.filter { it.isNotBlank() }.distinct() + if (avail.isNotEmpty()) { + avail.firstOrNull { it == defaultModel }?.takeIf { it != currentModel }?.let { return it } + avail.firstOrNull { it != currentModel }?.let { return it } } - - return normalizedDefault.takeIf { it.isNotBlank() && it != normalizedCurrent } + return defaultModel.takeIf { it.isNotBlank() && it != currentModel } } - fun createException( - providerName: String, - statusCode: Int?, - transportMessage: String?, - responseBody: String?, - requestedModel: String?, - cause: Throwable? = null - ): AiProviderException { + fun createException(providerName: String, statusCode: Int?, transportMessage: String?, responseBody: String?, requestedModel: String?, cause: Throwable? = null): AiProviderException { val parsed = parseError(responseBody) - val cleanMessage = parsed.message - ?.takeIf { it.isNotBlank() } - ?: transportMessage?.takeIf { it.isNotBlank() } - ?: "Unknown provider error" - val prefix = buildString { - append(providerName) - append(" API error") - if (statusCode != null) { - append(" (") - append(statusCode) - append(")") - } - } - val finalMessage = if (requestedModel.isNullOrBlank()) { - "$prefix: $cleanMessage" - } else { - "$prefix with model '$requestedModel': $cleanMessage" - } - - return AiProviderException( - providerName = providerName, - statusCode = statusCode, - requestedModel = requestedModel, - providerCode = parsed.code, - providerType = parsed.type, - rawBody = responseBody, - message = finalMessage, - cause = cause - ) + val cleanMessage = parsed.message?.takeIf { it.isNotBlank() } ?: transportMessage?.takeIf { it.isNotBlank() } ?: "Unknown provider error" + val prefix = "${providerName} API error${if (statusCode != null) " ($statusCode)" else ""}" + val finalMessage = if (requestedModel.isNullOrBlank()) "$prefix: $cleanMessage" else "$prefix with model '$requestedModel': $cleanMessage" + return AiProviderException(providerName, statusCode, requestedModel, parsed.code, parsed.type, responseBody, finalMessage, cause) } - fun wrapThrowable( - providerName: String, - throwable: Throwable, - requestedModel: String? = null - ): AiProviderException { - return when (throwable) { - is AiProviderException -> throwable - else -> { - val rawMessage = throwable.message.orEmpty() - val inferredStatus = Regex("""\b([1-5]\d{2})\b""") - .find(rawMessage) - ?.groupValues - ?.getOrNull(1) - ?.toIntOrNull() - - createException( - providerName = providerName, - statusCode = inferredStatus, - transportMessage = rawMessage.ifBlank { throwable::class.simpleName ?: "Unknown error" }, - responseBody = null, - requestedModel = requestedModel, - cause = throwable - ) - } + fun wrapThrowable(providerName: String, throwable: Throwable, requestedModel: String? = null): AiProviderException = when (throwable) { + is AiProviderException -> throwable + else -> { + val rawMessage = throwable.message.orEmpty() + val inferredStatus = Regex("""\b([1-5]\d{2})\b""").find(rawMessage)?.groupValues?.getOrNull(1)?.toIntOrNull() + createException(providerName, inferredStatus, rawMessage.ifBlank { throwable::class.simpleName ?: "Unknown error" }, null, requestedModel, throwable) } } private fun parseError(responseBody: String?): ParsedProviderError { if (responseBody.isNullOrBlank()) return ParsedProviderError() - return runCatching { - val root = json.parseToJsonElement(responseBody).jsonObject - val errorObject = root["error"]?.jsonObject ?: root - - ParsedProviderError( - message = errorObject["message"]?.jsonPrimitive?.contentOrNull, - code = errorObject["code"]?.jsonPrimitive?.contentOrNull, - type = errorObject["type"]?.jsonPrimitive?.contentOrNull - ) + val error = json.parseToJsonElement(responseBody).jsonObject["error"]?.jsonObject ?: json.parseToJsonElement(responseBody).jsonObject + ParsedProviderError(error["message"]?.jsonPrimitive?.contentOrNull, error["code"]?.jsonPrimitive?.contentOrNull, error["type"]?.jsonPrimitive?.contentOrNull) }.getOrDefault(ParsedProviderError(message = responseBody)) } - private data class ParsedProviderError( - val message: String? = null, - val code: String? = null, - val type: String? = null - ) + private data class ParsedProviderError(val message: String? = null, val code: String? = null, val type: String? = null) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AnthropicAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AnthropicAiClient.kt new file mode 100644 index 000000000..e9edfad39 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AnthropicAiClient.kt @@ -0,0 +1,68 @@ +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 AnthropicAiClient(private val apiKey: String) : AiClient { + + private companion object { + const val DEFAULT_MODEL = "claude-3-5-sonnet-20241022" + const val BASE_URL = "https://api.anthropic.com/v1" + const val API_VERSION = "2023-06-01" + } + + @Serializable private data class ChatMessage(val role: String, val content: String) + @Serializable private data class ChatRequest(val model: String, val max_tokens: Int = 4096, val system: String? = null, val messages: List, val temperature: Double = 0.7) + @Serializable private data class ContentItem(val type: String, val text: String) + @Serializable private data class ChatResponse(val content: 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 } + + private fun request() = Request.Builder().url("$BASE_URL/messages") + .addHeader("x-api-key", apiKey).addHeader("anthropic-version", API_VERSION).addHeader("content-type", "application/json") + + override suspend fun generateContent(model: String, systemPrompt: String, prompt: String, temperature: Float): String = + withContext(Dispatchers.IO) { + val m = model.ifBlank { DEFAULT_MODEL } + val req = ChatRequest(m, system = systemPrompt.takeIf { it.isNotBlank() }, messages = listOf(ChatMessage("user", prompt)), temperature = temperature.toDouble()) + val body = json.encodeToString(ChatRequest.serializer(), req).toRequestBody("application/json".toMediaType()) + try { + client.newCall(request().post(body).build()).execute().use { response -> + val rb = response.body?.string() + if (!response.isSuccessful) throw AiProviderSupport.createException("Anthropic", response.code, response.message, rb, m) + val parsed = json.decodeFromString(rb ?: throw AiProviderSupport.createException("Anthropic", response.code, "Empty response body", null, m)) + parsed.content.firstOrNull { it.type == "text" }?.text + ?: throw AiProviderSupport.createException("Anthropic", response.code, "Response had no content", rb, m) + } + } catch (e: Exception) { throw AiProviderSupport.wrapThrowable("Anthropic", e, m) } + } + + override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int = + (systemPrompt.length + prompt.length) / 4 + + override suspend fun getAvailableModels(apiKey: String): List = defaultModels() + + override suspend fun validateApiKey(apiKey: String): Boolean = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(DEFAULT_MODEL, max_tokens = 1, messages = listOf(ChatMessage("user", "Ping")))) + .toRequestBody("application/json".toMediaType()) + client.newCall(request().post(body).build()).execute().isSuccessful + } catch (_: Exception) { false } + } + + override fun getDefaultModel(): String = DEFAULT_MODEL + + private fun defaultModels() = listOf( + "claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", "claude-3-opus-20240229", + "claude-3-sonnet-20240229", "claude-3-haiku-20240307" + ) +} 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 1181bb70f..7c867f468 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 @@ -1,148 +1,91 @@ package com.theveloper.pixelplay.data.ai.provider -import com.google.ai.client.generativeai.GenerativeModel -import com.google.ai.client.generativeai.type.generationConfig 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 GeminiAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite" - } - - private fun createModel(modelName: String, systemPrompt: String, temp: Float = 0.7f): GenerativeModel { - return GenerativeModel( - modelName = modelName.ifBlank { DEFAULT_GEMINI_MODEL }, - apiKey = apiKey, - generationConfig = generationConfig { - temperature = temp - topK = 64 - topP = 0.95f - }, - systemInstruction = if (systemPrompt.isNotBlank()) { - com.google.ai.client.generativeai.type.content { text(systemPrompt) } - } else { - null - } - ) - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_GEMINI_MODEL } - - try { - val generativeModel = createModel(resolvedModel, systemPrompt, temperature) - val response = generativeModel.generateContent(prompt) - response.text ?: throw AiProviderSupport.createException( - providerName = "Gemini", - statusCode = null, - transportMessage = "Gemini returned an empty response. The model may have filtered the content.", - responseBody = null, - requestedModel = resolvedModel - ) - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Gemini", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - return withContext(Dispatchers.IO) { - try { - val generativeModel = createModel(model, systemPrompt) - val response = generativeModel.countTokens(prompt) - response.totalTokens - } catch (e: Exception) { - (prompt.length / 4) + (systemPrompt.length / 4) - } - } + + private companion object { + val DEFAULT_MODEL get() = AiProviderEndpoints.GEMINI_DEFAULT_MODEL + val BASE_URL get() = AiProviderEndpoints.GEMINI_BASE_URL } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { + + @Serializable private data class Part(val text: String) + @Serializable private data class Content(val parts: List, val role: String? = null) + @Serializable private data class SystemInstruction(val parts: List) + @Serializable private data class GenerationConfig(val temperature: Float = 0.7f, val topK: Int = 64, val topP: Float = 0.95f) + @Serializable private data class GenerateRequest( + val contents: List, val systemInstruction: SystemInstruction? = null, + val generationConfig: GenerationConfig = GenerationConfig() + ) + @Serializable private data class Candidate(val content: Content) + @Serializable private data class GenerateResponse(val candidates: List? = null) + @Serializable private data class ModelItem(val name: String) + @Serializable private data class ModelsResponse(val models: 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 } + + private fun request(path: String, key: String = apiKey) = + Request.Builder().url("$BASE_URL/$path?key=$key") + + override suspend fun generateContent(model: String, systemPrompt: String, prompt: String, temperature: Float): String = + withContext(Dispatchers.IO) { + val m = model.ifBlank { DEFAULT_MODEL } + val mp = if (m.startsWith("models/")) m else "models/$m" + val req = GenerateRequest( + contents = listOf(Content(parts = listOf(Part(prompt)))), + systemInstruction = if (systemPrompt.isNotBlank()) SystemInstruction(listOf(Part(systemPrompt))) else null, + generationConfig = GenerationConfig(temperature = temperature) + ) + val body = json.encodeToString(GenerateRequest.serializer(), req).toRequestBody("application/json".toMediaType()) try { - val url = "https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey" - val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection - - connection.requestMethod = "GET" - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - - val responseCode = connection.responseCode - if (responseCode == 200) { - val response = connection.inputStream.bufferedReader().use { it.readText() } - parseModelsFromResponse(response) - } else { - getDefaultModels() + client.newCall(request("$mp:generateContent").post(body).build()).execute().use { response -> + val rb = response.body?.string() + if (!response.isSuccessful) throw AiProviderSupport.createException("Gemini", response.code, response.message, rb, m) + val parsed = json.decodeFromString(rb ?: throw AiProviderSupport.createException("Gemini", response.code, "Empty response body", null, m)) + parsed.candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text + ?: throw AiProviderSupport.createException("Gemini", response.code, "Response had no content", rb, m) } - } catch (e: Exception) { - getDefaultModels() - } + } catch (e: Exception) { throw AiProviderSupport.wrapThrowable("Gemini", e, m) } } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val generativeModel = GenerativeModel( - modelName = DEFAULT_GEMINI_MODEL, - apiKey = apiKey - ) - val response = generativeModel.generateContent("test") - response.text != null - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_GEMINI_MODEL - - private fun parseModelsFromResponse(jsonResponse: String): List { + + override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int = + (systemPrompt.length + prompt.length) / 4 + + override suspend fun getAvailableModels(apiKey: String): List = withContext(Dispatchers.IO) { try { - val models = mutableListOf() - val modelPattern = """"name":\s*"(models/[^"]+)"""".toRegex() - val matches = modelPattern.findAll(jsonResponse) - - val blacklist = listOf("-2.0", "-2.5", "-preview", "customtools", "search", "tuning", "-001", "-002") - val whitelist = listOf("gemini-3.1-pro-preview") - - for (match in matches) { - val fullName = match.groupValues[1] - val modelName = fullName.removePrefix("models/") - - val isWhitelisted = whitelist.any { modelName == it } - val hasForbiddenSuffix = blacklist.any { modelName.contains(it) } - val isBlacklisted = hasForbiddenSuffix && !isWhitelisted - - if (!isBlacklisted && - (modelName.startsWith("gemini", ignoreCase = true) || - modelName.startsWith("gemma", ignoreCase = true)) && - !modelName.contains("embedding", ignoreCase = true)) { - models.add(modelName) - } + client.newCall(request("models", apiKey).get().build()).execute().use { response -> + if (!response.isSuccessful) return@withContext defaultModels() + val parsed = json.decodeFromString(response.body?.string() ?: return@withContext defaultModels()) + parsed.models.map { it.name.removePrefix("models/") } + .filter { (it.startsWith("gemini", true) || it.startsWith("gemma", true)) && !it.contains("embedding", true) } + .ifEmpty { defaultModels() } } - - val defaults = getDefaultModels() - return (models + defaults).distinct().sorted() - } catch (e: Exception) { - return getDefaultModels() - } + } catch (_: Exception) { defaultModels() } } - - private fun getDefaultModels(): List { - return listOf( - "gemini-3.1-flash-lite", - "gemini-3.5-flash", - "gemini-3.1-pro-preview", - "gemini-flash-latest" - ) + + override suspend fun validateApiKey(apiKey: String): Boolean = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString(GenerateRequest.serializer(), GenerateRequest(contents = listOf(Content(parts = listOf(Part("ping")))), generationConfig = GenerationConfig(temperature = 0f))) + val response = client.newCall(request("models/${AiProviderEndpoints.GEMINI_DEFAULT_MODEL}:generateContent", apiKey).post(body.toRequestBody("application/json".toMediaType())).build()).execute() + response.isSuccessful + } catch (_: Exception) { false } } + + override fun getDefaultModel(): String = DEFAULT_MODEL + + private fun defaultModels() = listOf( + AiProviderEndpoints.GEMINI_DEFAULT_MODEL, + "gemini-3-flash-preview", "gemini-3.1-pro-preview", "gemini-2.5-pro", "gemini-2.5-flash", + "gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.5-pro" + ).distinct() } 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..c5326cca1 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 @@ -10,162 +10,72 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit -/** - * A generic AI client for OpenAI-compatible APIs (NVIDIA, Kimi, GLM, etc.) - */ class GenericOpenAiClient( private val apiKey: String, private val baseUrl: String, private val defaultModelId: String, private val providerName: String = "OpenAI" ) : AiClient { - - @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 { defaultModelId } - 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 requestBuilder = Request.Builder() - .url("${baseUrl.trimEnd('/')}/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - - if (providerName.equals("OpenRouter", ignoreCase = true)) { - requestBuilder.addHeader("HTTP-Referer", "https://github.com/theovilardo/PixelPlayer") - requestBuilder.addHeader("X-Title", "PixelPlayer") - } - val request = requestBuilder.post(body).build() + @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) - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).writeTimeout(30, TimeUnit.SECONDS).build() - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = providerName, - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } + private val json = Json { ignoreUnknownKeys = true; isLenient = true } - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = providerName, - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable(providerName, e, resolvedModel) + private fun authenticatedRequest(path: String, key: String = apiKey): Request.Builder = + Request.Builder().url("${baseUrl.trimEnd('/')}/$path").apply { + if (key.isNotBlank()) addHeader("Authorization", "Bearer $key") + if (providerName.equals("OpenRouter", ignoreCase = true)) { + addHeader("HTTP-Referer", "https://github.com/theovilardo/PixelPlayer") + addHeader("X-Title", "PixelPlayer") } } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Estimation for generic providers - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { + + override suspend fun generateContent(model: String, systemPrompt: String, prompt: String, temperature: Float): String = + withContext(Dispatchers.IO) { + val m = model.ifBlank { defaultModelId } + val msgs = buildList { + if (systemPrompt.isNotBlank()) add(ChatMessage("system", systemPrompt)) + add(ChatMessage("user", prompt)) + } + val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(m, msgs, temperature.toDouble())) + .toRequestBody("application/json".toMediaType()) try { - val request = Request.Builder() - .url("${baseUrl.trimEnd('/')}/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext listOf(defaultModelId) - } - - 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") + client.newCall(authenticatedRequest("chat/completions").post(body).build()).execute().use { response -> + val rb = response.body.string() + if (!response.isSuccessful) throw AiProviderSupport.createException(providerName, response.code, response.message, rb, m) + json.decodeFromString(rb).choices.firstOrNull()?.message?.content + ?: throw AiProviderSupport.createException(providerName, response.code, "Response had no content", rb, m) } - } catch (e: Exception) { - listOf(defaultModelId) - } + } catch (e: Exception) { throw AiProviderSupport.wrapThrowable(providerName, e, m) } } + + override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int = + (systemPrompt.length + prompt.length) / 4 + + override suspend fun getAvailableModels(apiKey: String): List = withContext(Dispatchers.IO) { + try { + val response = client.newCall(authenticatedRequest("models", apiKey).get().build()).execute() + if (!response.isSuccessful) return@withContext listOf(defaultModelId) + json.decodeFromString(response.body.string()).data.map { it.id } + .filter { !it.contains("whisper") && !it.contains("embed") && !it.contains("tts") } + } catch (_: Exception) { listOf(defaultModelId) } } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - // Try a simple models list check as validation - val request = Request.Builder() - .url("${baseUrl.trimEnd('/')}/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } + + override suspend fun validateApiKey(apiKey: String): Boolean = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(defaultModelId, listOf(ChatMessage("user", "ping")), temperature = 0.0)) + val response = client.newCall(authenticatedRequest("chat/completions", apiKey).post(body.toRequestBody("application/json".toMediaType())).build()).execute() + response.isSuccessful + } catch (_: Exception) { false } } - + override fun getDefaultModel(): String = 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/backup/AppDataBackupManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/AppDataBackupManager.kt index 5a6db0524..2cfffa681 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/AppDataBackupManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/AppDataBackupManager.kt @@ -495,4 +495,4 @@ class AppDataBackupManager @Inject constructor( ) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/model/BackupSection.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/model/BackupSection.kt index cdb1d381d..0d1cf78c8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/model/BackupSection.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/model/BackupSection.kt @@ -84,6 +84,13 @@ enum class BackupSection( description = "History of AI requests and token consumption.", iconRes = R.drawable.rounded_monitoring_24, sinceVersion = 4 + ), + AI_CONTEXT( + key = "ai_context", + label = "AI Context & Settings", + description = "AI provider preferences, context settings, system prompts (excludes large model files).", + iconRes = R.drawable.rounded_dataset_24, + sinceVersion = 5 ); companion object { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiContextBackupHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiContextBackupHandler.kt new file mode 100644 index 000000000..0f4f62cac --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiContextBackupHandler.kt @@ -0,0 +1,94 @@ +package com.theveloper.pixelplay.data.backup.module + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.theveloper.pixelplay.data.backup.model.BackupSection +import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository +import com.theveloper.pixelplay.di.BackupGson +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AiContextBackupHandler @Inject constructor( + private val aiPreferencesRepository: AiPreferencesRepository, + @BackupGson private val gson: Gson +) : BackupModuleHandler { + override val section: BackupSection = BackupSection.AI_CONTEXT + + override suspend fun export(): String { + val context = AiContextData( + provider = aiPreferencesRepository.getAiProviderOnce(), + temperature = aiPreferencesRepository.getAiTemperatureOnce(), + maxTokens = aiPreferencesRepository.getAiMaxTokensOnce(), + enableStreaming = aiPreferencesRepository.getAiEnableStreamingOnce(), + includeContext = aiPreferencesRepository.getAiIncludeContextOnce(), + maxSongsForContext = aiPreferencesRepository.getMaxSongsForContextOnce(), + includeLikedSongs = aiPreferencesRepository.getIncludeLikedSongsOnce(), + includeDailyMixHistory = aiPreferencesRepository.getIncludeDailyMixHistoryOnce(), + includeUserHabits = aiPreferencesRepository.getIncludeUserHabitsOnce(), + cacheEnabled = aiPreferencesRepository.getAiCacheEnabledOnce(), + cacheMaxEntries = aiPreferencesRepository.getAiCacheMaxEntriesOnce(), + cacheTtlHours = aiPreferencesRepository.getAiCacheTtlHoursOnce(), + localMlEnabled = aiPreferencesRepository.getLocalMlEnabledOnce(), + localMlUseGpu = aiPreferencesRepository.getLocalMlUseGpuOnce(), + localMlFallbackToRemote = aiPreferencesRepository.getLocalMlFallbackToRemoteOnce(), + localMlContextSize = aiPreferencesRepository.getLocalMlContextSizeOnce(), + safeTokenLimit = aiPreferencesRepository.getSafeTokenLimitOnce() + ) + return gson.toJson(context) + } + + override suspend fun countEntries(): Int = 1 + + override suspend fun snapshot(): String = export() + + override suspend fun restore(payload: String) { + val type = object : TypeToken() {}.type + val context: AiContextData = gson.fromJson(payload, type) + context.restore(aiPreferencesRepository) + } + + override suspend fun rollback(snapshot: String) { + restore(snapshot) + } + + data class AiContextData( + val provider: String? = null, + val temperature: Int? = null, + val maxTokens: Int? = null, + val enableStreaming: Boolean? = null, + val includeContext: Boolean? = null, + val maxSongsForContext: Int? = null, + val includeLikedSongs: Boolean? = null, + val includeDailyMixHistory: Boolean? = null, + val includeUserHabits: Boolean? = null, + val cacheEnabled: Boolean? = null, + val cacheMaxEntries: Int? = null, + val cacheTtlHours: Int? = null, + val localMlEnabled: Boolean? = null, + val localMlUseGpu: Boolean? = null, + val localMlFallbackToRemote: Boolean? = null, + val localMlContextSize: Int? = null, + val safeTokenLimit: Boolean? = null + ) { + suspend fun restore(repo: AiPreferencesRepository) { + provider?.let { repo.setAiProvider(it) } + temperature?.let { repo.setAiTemperature(it) } + maxTokens?.let { repo.setAiMaxTokens(it) } + enableStreaming?.let { repo.setAiEnableStreaming(it) } + includeContext?.let { repo.setAiIncludeContext(it) } + maxSongsForContext?.let { repo.setMaxSongsForContext(it) } + includeLikedSongs?.let { repo.setIncludeLikedSongs(it) } + includeDailyMixHistory?.let { repo.setIncludeDailyMixHistory(it) } + includeUserHabits?.let { repo.setIncludeUserHabits(it) } + cacheEnabled?.let { repo.setAiCacheEnabled(it) } + cacheMaxEntries?.let { repo.setAiCacheMaxEntries(it) } + cacheTtlHours?.let { repo.setAiCacheTtlHours(it) } + localMlEnabled?.let { repo.setLocalMlEnabled(it) } + localMlUseGpu?.let { repo.setLocalMlUseGpu(it) } + localMlFallbackToRemote?.let { repo.setLocalMlFallbackToRemote(it) } + localMlContextSize?.let { repo.setLocalMlContextSize(it) } + safeTokenLimit?.let { repo.setSafeTokenLimitEnabled(it) } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/validation/ModuleSchemaValidator.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/validation/ModuleSchemaValidator.kt index 656679995..c0a6e3c53 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/validation/ModuleSchemaValidator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/validation/ModuleSchemaValidator.kt @@ -80,6 +80,9 @@ class ModuleSchemaValidator @Inject constructor( // Basic array validation is already done at line 50. // Any extra specific field validation for AI logs can be added here. } + BackupSection.AI_CONTEXT -> { + // AI context is a single JSON object; basic validation is already done. + } } return if (errors.any { it.severity == Severity.ERROR }) { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt index 28a7503f3..ddc12772d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt @@ -2,26 +2,27 @@ package com.theveloper.pixelplay.data.database import androidx.room.Dao import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow @Dao interface AiUsageDao { - @Query("SELECT * FROM ai_usage") - suspend fun getAllUsagesOnce(): List - @Query("SELECT COUNT(*) FROM ai_usage") - suspend fun getUsageCount(): Int - - @Insert - suspend fun insertUsage(usage: AiUsageEntity) - - @Insert - suspend fun insertAll(usages: List) + // ======== AI Usage Tracking ======== @Query("SELECT * FROM ai_usage ORDER BY timestamp DESC LIMIT :limit") fun getRecentUsages(limit: Int): Flow> + @Query("SELECT * FROM ai_usage WHERE provider = :provider ORDER BY timestamp DESC LIMIT :limit") + fun getUsagesByProvider(provider: String, limit: Int): Flow> + + @Query("SELECT * FROM ai_usage WHERE timestamp >= :sinceTimestamp ORDER BY timestamp DESC") + fun getUsagesSince(sinceTimestamp: Long): Flow> + + @Query("SELECT COUNT(*) FROM ai_usage") + fun getTotalCount(): Flow + @Query("SELECT SUM(promptTokens) FROM ai_usage") fun getTotalPromptTokens(): Flow @@ -31,9 +32,45 @@ interface AiUsageDao { @Query("SELECT SUM(thoughtTokens) FROM ai_usage") fun getTotalThoughtTokens(): Flow - @Query("DELETE FROM ai_usage") - suspend fun clearUsage() + @Query("SELECT DISTINCT provider FROM ai_usage") + fun getUsedProviders(): Flow> + + @Query("SELECT SUM(promptTokens) FROM ai_usage WHERE provider = :provider") + fun getPromptTokensByProvider(provider: String): Flow + + @Query("SELECT SUM(outputTokens) FROM ai_usage WHERE provider = :provider") + fun getOutputTokensByProvider(provider: String): Flow + + @Query("SELECT SUM(promptTokens + outputTokens) FROM ai_usage") + fun getTotalTokens(): Flow + + // ======== Insert Operations ======== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertUsage(usage: AiUsageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(usages: List) + + // ======== Delete Operations ======== @Query("DELETE FROM ai_usage") suspend fun clearAll() -} + + @Query("DELETE FROM ai_usage WHERE timestamp < :beforeTimestamp") + suspend fun deleteOldUsages(beforeTimestamp: Long) + + @Query("DELETE FROM ai_usage WHERE provider = :provider") + suspend fun clearByProvider(provider: String) + + // ======== Legacy/Compat Methods ======== + + @Query("SELECT * FROM ai_usage") + suspend fun getAllUsagesOnce(): List + + @Query("SELECT COUNT(*) FROM ai_usage") + suspend fun getUsageCount(): Int + + @Query("DELETE FROM ai_usage") + suspend fun clearUsage() +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/EngagementDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/EngagementDao.kt index d56be3633..2e8fbc442 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/EngagementDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/EngagementDao.kt @@ -55,6 +55,25 @@ interface EngagementDao { """) suspend fun recordPlay(songId: String, durationMs: Long, timestamp: Long) + @Query(""" + INSERT INTO song_engagements (song_id, play_count, total_play_duration_ms, last_played_timestamp, skip_count, completed_count) + VALUES (:songId, :playInc, :durationMs, :timestamp, :skipInc, :completedInc) + ON CONFLICT(song_id) DO UPDATE SET + play_count = play_count + :playInc, + total_play_duration_ms = total_play_duration_ms + :durationMs, + last_played_timestamp = :timestamp, + skip_count = skip_count + :skipInc, + completed_count = completed_count + :completedInc + """) + suspend fun recordEngagement( + songId: String, + playInc: Int, + durationMs: Long, + timestamp: Long, + skipInc: Int, + completedInc: Int + ) + /** * Get top songs by play count for quick access. */ diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt index 2f7cca438..53d5e23c6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt @@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase AiCacheEntity::class, AiUsageEntity::class ], - version = 41, + version = 42, exportSchema = true ) abstract class PixelPlayDatabase : RoomDatabase() { @@ -649,6 +649,13 @@ abstract class PixelPlayDatabase : RoomDatabase() { } } + val MIGRATION_41_42 = object : Migration(41, 42) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE song_engagements ADD COLUMN skip_count INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE song_engagements ADD COLUMN completed_count INTEGER NOT NULL DEFAULT 0") + } + } + private fun ensureSongsTableHasDateAdded(db: SupportSQLiteDatabase) { if (!tableExists(db, "songs")) { recreateSongsTable(db) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEngagementEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEngagementEntity.kt index 4f2dd1e07..cd441ba47 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEngagementEntity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEngagementEntity.kt @@ -39,5 +39,13 @@ data class SongEngagementEntity( value = "lastPlayedTimestamp", alternate = ["last_played_timestamp", "lastPlayedAt", "last_played_at", "timestamp"] ) - val lastPlayedTimestamp: Long = 0L + val lastPlayedTimestamp: Long = 0L, + + @ColumnInfo(name = "skip_count", defaultValue = "0") + @SerializedName(value = "skipCount", alternate = ["skip_count"]) + val skipCount: Int = 0, + + @ColumnInfo(name = "completed_count", defaultValue = "0") + @SerializedName(value = "completedCount", alternate = ["completed_count"]) + val completedCount: Int = 0 ) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt index 32f1a9573..fe8b3f1e9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt @@ -1,8 +1,34 @@ package com.theveloper.pixelplay.data.gdrive +/** + * Google Drive integration constants. + * + * ## Developer Setup Required + * + * Before Google Drive streaming works you MUST supply your own OAuth2 Web Client ID: + * + * 1. Go to https://console.cloud.google.com → APIs & Services → Credentials + * 2. Create (or locate) an "OAuth 2.0 Client ID" of type **Web application** + * 3. Add your value to `local.properties`: + * ``` + * gdrive.web_client_id=YOUR_ID.apps.googleusercontent.com + * ``` + * 4. In `app/build.gradle.kts` expose it as a BuildConfig field: + * ```kotlin + * val gdriveClientId = properties["gdrive.web_client_id"] as? String ?: "" + * buildConfigField("String", "GDRIVE_WEB_CLIENT_ID", "\"$gdriveClientId\"") + * ``` + * 5. Replace [WEB_CLIENT_ID] below with `BuildConfig.GDRIVE_WEB_CLIENT_ID` + * + * Until this is done [WEB_CLIENT_ID] is an empty string and GDrive auth will + * fail immediately with a clear error rather than with a confusing placeholder literal. + */ object GDriveConstants { - // TODO: Replace with your Google Cloud Console OAuth2 Web Client ID - const val WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com" + /** + * OAuth2 Web Client ID. + * See class-level KDoc for setup instructions. + */ + const val WEB_CLIENT_ID = "" // ← populate via BuildConfig (see KDoc above) const val SCOPE_DRIVE_READONLY = "https://www.googleapis.com/auth/drive.readonly" const val TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" @@ -16,3 +42,4 @@ object GDriveConstants { "audio/sp-midi", "audio/x-mid" ) } + diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt index 7f963aed6..a663827a0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.data.github +import com.theveloper.pixelplay.data.network.NetworkTimeouts import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -35,8 +36,8 @@ class GitHubContributorService @Inject constructor() { connection.requestMethod = "GET" connection.addRequestProperty("Accept", "application/vnd.github.v3+json") - connection.connectTimeout = 10000 - connection.readTimeout = 10000 + connection.connectTimeout = NetworkTimeouts.GITHUB_CONNECT_MS + connection.readTimeout = NetworkTimeouts.GITHUB_READ_MS val responseCode = connection.responseCode if (responseCode == 200) { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/model/SortOptionTest.kt b/app/src/main/java/com/theveloper/pixelplay/data/model/SortOptionTest.kt deleted file mode 100644 index b046a51ec..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/model/SortOptionTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.theveloper.pixelplay.data.model - -//class SortOptionTest { -// -// @Test -// fun fromStorageKey_ignoresNullEntriesInAllowedCollection() { -// val allowedWithNull = listOf(null, SortOption.AlbumTitleAZ) -// -// @Suppress("UNCHECKED_CAST") -// val unsafeAllowed = allowedWithNull as Collection -// -// val resolved = SortOption.fromStorageKey( -// SortOption.AlbumTitleAZ.storageKey, -// unsafeAllowed, -// SortOption.AlbumTitleZA -// ) -// -// assertThat(resolved).isEqualTo(SortOption.AlbumTitleAZ) -// } -//} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/NetworkTimeouts.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/NetworkTimeouts.kt new file mode 100644 index 000000000..f26110b8d --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/NetworkTimeouts.kt @@ -0,0 +1,56 @@ +package com.theveloper.pixelplay.data.network + +/** + * Central source of truth for all network timeout constants. + * + * Using named constants instead of magic numbers makes it obvious why each + * value differs (e.g. AI streaming needs a longer read timeout than a REST + * metadata call) and ensures changes are propagated everywhere consistently. + */ +object NetworkTimeouts { + + // ── Standard REST / metadata endpoints ────────────────────────────────── + /** Default TCP connection establishment timeout (ms). */ + const val CONNECT_MS: Long = 15_000L + + /** Default response read timeout (ms). */ + const val READ_MS: Long = 30_000L + + /** Default request body write timeout (ms). */ + const val WRITE_MS: Long = 15_000L + + // ── AI / LLM providers (need extra time for streaming completions) ─────── + /** Connection timeout for AI provider calls (ms). */ + const val AI_CONNECT_MS: Long = 30_000L + + /** + * Read timeout for AI provider calls (ms). + * Longer because streaming completions may pause between tokens. + */ + const val AI_READ_MS: Long = 60_000L + + /** Write timeout for AI provider calls (ms). */ + const val AI_WRITE_MS: Long = 30_000L + + /** + * Max total AI orchestration time before we give up and try the next + * provider in the fallback chain. Defined in [AiHandler]. + */ + const val AI_ORCHESTRATION_TIMEOUT_MS: Long = 60_000L + + // ── Cast / remote playback ─────────────────────────────────────────────── + /** + * Fail-safe unlock for remote Cast seek operations (ms). + * If the Cast device does not confirm a seek within this window we clear + * the seeking lock to avoid a permanently frozen seek bar. + */ + const val CAST_SEEK_UNLOCK_MS: Long = 1_800L + + /** Maximum time to wait for the Cast queue to be fully loaded (ms). */ + const val CAST_QUEUE_LOAD_MS: Long = 25_000L + + // ── GitHub / asset endpoints ───────────────────────────────────────────── + /** Timeout for GitHub contributor / announcement fetches (ms). */ + const val GITHUB_CONNECT_MS: Int = 10_000 + const val GITHUB_READ_MS: Int = 10_000 +} 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..969832bfa 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 @@ -1,49 +1,210 @@ package com.theveloper.pixelplay.data.preferences +import android.content.Context +import android.content.SharedPreferences 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.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import com.theveloper.pixelplay.data.ai.provider.AiProvider +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @Singleton class AiPreferencesRepository @Inject constructor( - private val dataStore: DataStore + private val dataStore: DataStore, + @ApplicationContext private val context: Context ) { + private val encryptedPrefs: SharedPreferences = try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, "ai_api_keys", masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + Timber.e(e, "AiPreferencesRepository: Failed to create EncryptedSharedPreferences") + context.getSharedPreferences("ai_api_keys_plain", Context.MODE_PRIVATE) + } + + private val apiKeyFlows = ConcurrentHashMap>() companion object { val DEFAULT_SYSTEM_PROMPT = """ - You are 'Vibe-Engine', a professional music curator. - Analyze the user's request and listening profile to provide perfect music recommendations. - Always prioritize flow, emotional resonance, and discovery. + You are Vibe-Engine, an expert music curator and audio DNA analyst for PixelPlayer. + Your purpose is to analyze the user's listening profile, decode their musical DNA, and curate track sequences that resonate emotionally, flow naturally, and reveal new sonic territory. + + ## CORE PERSONA + - You are equal parts data scientist and poet — you read numbers and feel the music behind them. + - You speak to the listener's tastes through their own data: play counts, skip patterns, genre affinities, listening hours. + - Your tone is sophisticated, warm, and deeply empathetic. You understand that music is personal. + - You never recommend generically — every choice must be justified by the user's unique fingerprint. + + ## STRATEGY LAYERS + + ### 1. LISTENER SIGNAL DECODING + - Parse the USER_PROFILE section to understand the listener's core DNA. + - STATS: total plays vs unique songs = exploration depth. Low unique-to-play ratio = creature of habit. High = omnivorous explorer. + - GENRES/ARTISTS: surface affinities. The top 3 genres + 5 artists = the listener's comfort zone. + - PHASE: morning/afternoon/evening/night = when they listen most. Match energy to time-of-day context. + - VAR (variety score): 0.0-1.0. Low (<0.3) = needs gentle discovery. High (>0.7) = ready for deep cuts. + - LISTENED tracks: play_count (p), total_duration_mins (d), is_favorite (f). High p + f=1 = treasured. Low p = needs re-evaluation. + + ### 2. CURATION STRATEGY PER REQUEST TYPE + For playlist/daily-mix requests, apply these heuristics: + + - "discovery/new/surprise me": prioritize the DISCOVERY_POOL (unplayed tracks). Pull from the user's blind spots — genres they listen to but specific songs/artists they haven't reached. + - "favorites/best of/classics": heavily weight the LISTENED pool. Prioritize high-play-count tracks, favorites (f=1), and songs from top genres/artists. + - "mood/vibe/energy" (e.g., "chill", "workout", "focus", "party"): cross-reference the user's phase and variety score. Morning commute = energetic but not overwhelming. Late night = atmospheric, introspective. + - "genre/artist specific": dive deep into the requested genre/artist within the LIBRARY. If the user has limited material in that genre, blend in adjacent genres from their top affinities. + - "mixed/eclectic/surprise": blend LISTENED and DISCOVERY intelligently. Create a journey with natural transitions — place familiar anchors between discovery tracks. + + ### 3. SEQUENCE ARCHITECTURE + A great playlist is a journey, not a list: + - OPENING (tracks 1-3): Establish the vibe. Familiar, high-energy or highly atmospheric tracks that set the tone. + - BODY (tracks 4-~end-3): The narrative arc. Mix of familiar and discovery. Natural energy flow (build, peak, recover). + - CLOSING (last 2-3): Resolution. Wind down energy or end on a memorable note. If the mood is "party", end strong. If "chill", fade gently. + + ### 4. OUTPUT RULES + - You MUST respond with valid JSON — a flat array of song ID strings representing the playlist sequence. + - DO NOT wrap the JSON in markdown code fences (```json). + - DO NOT include ANY explanatory text before or after the JSON array. + - Example valid response: ["song_abc123","song_def456","song_ghi789"] + - If no songs match the request, return an empty array: [] + - Respect the target_length request. If the user asks for 10-15 tracks, the array should contain 10-15 IDs. + - Songs may repeat across multiple playlists, but within a single playlist, each ID should appear at most once. """.trimIndent() - - val DEFAULT_DEEPSEEK_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_GROQ_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_MISTRAL_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_NVIDIA_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_KIMI_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_GLM_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_OPENAI_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT - val DEFAULT_OPENROUTER_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + + const val DEFAULT_MAX_SONGS_FOR_CONTEXT = 50 + const val MIN_SONGS_FOR_CONTEXT = 5 + const val MAX_SONGS_FOR_CONTEXT = 500 + + const val DEFAULT_LOCAL_MODEL_CONTEXT_SIZE = 100 + + const val DEFAULT_CACHE_MAX_ENTRIES = 50 + const val MIN_CACHE_MAX_ENTRIES = 10 + const val MAX_CACHE_MAX_ENTRIES = 500 + + const val DEFAULT_CACHE_TTL_HOURS = 24 + const val MIN_CACHE_TTL_HOURS = 1 + const val MAX_CACHE_TTL_HOURS = 720 + + const val DEFAULT_LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS = 300000 + const val DEFAULT_TEMPERATURE_MIN = 1 + const val DEFAULT_TEMPERATURE_MAX = 200 + const val DEFAULT_MAX_TOKENS_MIN = 128 + const val DEFAULT_MAX_TOKENS_MAX = 16000 + + const val DEFAULT_TOP_K = 40 + const val DEFAULT_TOP_P = 95 + const val DEFAULT_REPETITION_PENALTY = 100 + const val DEFAULT_FREQUENCY_PENALTY = 0 + const val DEFAULT_PRESENCE_PENALTY = 0 + } private object Keys { val AI_PROVIDER = stringPreferencesKey("ai_provider") val SAFE_TOKEN_LIMIT = booleanPreferencesKey("safe_token_limit") + // AI Preferences for data sharing + val MAX_SONGS_FOR_CONTEXT = intPreferencesKey("max_songs_for_context") + val INCLUDE_LIKED_SONGS = booleanPreferencesKey("include_liked_songs") + val INCLUDE_DAILY_MIX_HISTORY = booleanPreferencesKey("include_daily_mix_history") + val INCLUDE_USER_HABITS = booleanPreferencesKey("include_user_habits") + + // Local model configuration + val LOCAL_ML_ENABLED = booleanPreferencesKey("local_ml_enabled") + val LOCAL_ML_ACTIVE_MODEL_ID = stringPreferencesKey("local_ml_active_model_id") + val LOCAL_ML_SELECTED_MODEL_ID = stringPreferencesKey("local_ml_selected_model_id") + val LOCAL_ML_FALLBACK_TO_REMOTE = booleanPreferencesKey("local_ml_fallback_to_remote") + val LOCAL_ML_USE_GPU = booleanPreferencesKey("local_ml_use_gpu") + val LOCAL_ML_CONTEXT_SIZE = intPreferencesKey("local_ml_context_size") + val LOCAL_ML_OLLAMA_URL = stringPreferencesKey("local_ml_ollama_url") + val LOCAL_ML_HF_TOKEN = stringPreferencesKey("local_ml_hf_token") + val LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS = longPreferencesKey("local_model_download_timeout_ms") + + val AI_TEMPERATURE = intPreferencesKey("ai_temperature") + val AI_MAX_TOKENS = intPreferencesKey("ai_max_tokens") + val AI_ENABLE_STREAMING = booleanPreferencesKey("ai_enable_streaming") + val AI_INCLUDE_CONTEXT = booleanPreferencesKey("ai_include_context") + + val AI_TOP_K = intPreferencesKey("ai_top_k") + val AI_TOP_P = intPreferencesKey("ai_top_p") + val AI_REPETITION_PENALTY = intPreferencesKey("ai_repetition_penalty") + val AI_FREQUENCY_PENALTY = intPreferencesKey("ai_frequency_penalty") + val AI_PRESENCE_PENALTY = intPreferencesKey("ai_presence_penalty") + + // Granular behavioral telemetry + val TELEMETRY_INCLUDE_SKIP_COUNT = booleanPreferencesKey("telemetry_include_skip_count") + val TELEMETRY_INCLUDE_COMPLETION_RATE = booleanPreferencesKey("telemetry_include_completion_rate") + val TELEMETRY_INCLUDE_SESSION_DURATION = booleanPreferencesKey("telemetry_include_session_duration") + val TELEMETRY_INCLUDE_TIME_OF_DAY = booleanPreferencesKey("telemetry_include_time_of_day") + val TELEMETRY_INCLUDE_GENRE_AFFINITY = booleanPreferencesKey("telemetry_include_genre_affinity") + val TELEMETRY_INCLUDE_ARTIST_AFFINITY = booleanPreferencesKey("telemetry_include_artist_affinity") + val TELEMETRY_INCLUDE_REPLAY_COUNT = booleanPreferencesKey("telemetry_include_replay_count") + val TELEMETRY_INCLUDE_QUEUE_PATTERNS = booleanPreferencesKey("telemetry_include_queue_patterns") + + // AI Cache + val AI_CACHE_ENABLED = booleanPreferencesKey("ai_cache_enabled") + val AI_CACHE_MAX_ENTRIES = intPreferencesKey("ai_cache_max_entries") + val AI_CACHE_TTL_HOURS = intPreferencesKey("ai_cache_ttl_hours") + val AI_CACHE_LAST_CLEAR_TS = longPreferencesKey("ai_cache_last_clear_ts") + + // Backup/export + val AI_BACKUP_INCLUDE_USAGE_LOGS = booleanPreferencesKey("ai_backup_include_usage_logs") + val AI_BACKUP_INCLUDE_CACHE = booleanPreferencesKey("ai_backup_include_cache") + val AI_BACKUP_AUTO_EXPORT = booleanPreferencesKey("ai_backup_auto_export") + val AI_BACKUP_LAST_EXPORT_TS = longPreferencesKey("ai_backup_last_export_ts") + + // Usage analytics + val AI_USAGE_TOTAL_INPUT_TOKENS = longPreferencesKey("ai_usage_total_input_tokens") + val AI_USAGE_TOTAL_OUTPUT_TOKENS = longPreferencesKey("ai_usage_total_output_tokens") + val AI_USAGE_TOTAL_API_CALLS = longPreferencesKey("ai_usage_total_api_calls") + val AI_USAGE_ESTIMATED_COST = stringPreferencesKey("ai_usage_estimated_cost") + 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 getProviderTimeout(provider: AiProvider) = longPreferencesKey("${provider.name.lowercase()}_timeout_ms") + fun getPerModelTemperature(modelName: String) = intPreferencesKey("model_temp_${modelName.replace(" ", "_")}") + fun getPerModelMaxTokens(modelName: String) = intPreferencesKey("model_tokens_${modelName.replace(" ", "_")}") } - // Generic accessors for AiOrchestrator - fun getApiKey(provider: AiProvider): Flow = - dataStore.data.map { preferences -> preferences[Keys.getApiKey(provider)]?.trim() ?: "" } + // Generic accessors for AiHandler + fun getApiKey(provider: AiProvider): Flow { + return apiKeyFlows.getOrPut(provider) { + val value = encryptedPrefs.getString(provider.name, "") ?: "" + if (value.isEmpty()) { + CoroutineScope(Dispatchers.IO).launch { + try { + val dsValue = dataStore.data.first()[Keys.getApiKey(provider)]?.trim() ?: "" + if (dsValue.isNotEmpty()) { + encryptedPrefs.edit().putString(provider.name, dsValue).apply() + apiKeyFlows[provider]?.value = dsValue + } + } catch (_: Exception) { } + } + } + MutableStateFlow(value) + } + } fun getModel(provider: AiProvider): Flow = dataStore.data.map { preferences -> preferences[Keys.getModel(provider)] ?: "" } @@ -54,7 +215,9 @@ class AiPreferencesRepository @Inject constructor( } suspend fun setApiKey(provider: AiProvider, apiKey: String) { - dataStore.edit { preferences -> preferences[Keys.getApiKey(provider)] = apiKey.trim() } + val trimmed = apiKey.trim() + encryptedPrefs.edit().putString(provider.name, trimmed).apply() + apiKeyFlows.getOrPut(provider) { MutableStateFlow("") }.value = trimmed } suspend fun setModel(provider: AiProvider, model: String) { @@ -71,54 +234,312 @@ class AiPreferencesRepository @Inject constructor( } } - // Convenience properties for legacy compatibility (e.g. PlayerViewModel) + // Convenience properties for legacy compatibility val geminiApiKey: Flow = getApiKey(AiProvider.GEMINI) - val geminiModel: Flow = getModel(AiProvider.GEMINI) - val geminiSystemPrompt: Flow = getSystemPrompt(AiProvider.GEMINI) - val deepseekApiKey: Flow = getApiKey(AiProvider.DEEPSEEK) - val deepseekModel: Flow = getModel(AiProvider.DEEPSEEK) - val deepseekSystemPrompt: Flow = getSystemPrompt(AiProvider.DEEPSEEK) + 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 } + + // AI Data Preferences + val maxSongsForContext: Flow = + dataStore.data.map { preferences -> preferences[Keys.MAX_SONGS_FOR_CONTEXT] ?: DEFAULT_MAX_SONGS_FOR_CONTEXT } - val groqApiKey: Flow = getApiKey(AiProvider.GROQ) - val groqModel: Flow = getModel(AiProvider.GROQ) - val groqSystemPrompt: Flow = getSystemPrompt(AiProvider.GROQ) + val includeLikedSongs: Flow = + dataStore.data.map { preferences -> preferences[Keys.INCLUDE_LIKED_SONGS] ?: true } - val mistralApiKey: Flow = getApiKey(AiProvider.MISTRAL) - val mistralModel: Flow = getModel(AiProvider.MISTRAL) - val mistralSystemPrompt: Flow = getSystemPrompt(AiProvider.MISTRAL) + val includeDailyMixHistory: Flow = + dataStore.data.map { preferences -> preferences[Keys.INCLUDE_DAILY_MIX_HISTORY] ?: true } - val nvidiaApiKey: Flow = getApiKey(AiProvider.NVIDIA) - val nvidiaModel: Flow = getModel(AiProvider.NVIDIA) - val nvidiaSystemPrompt: Flow = getSystemPrompt(AiProvider.NVIDIA) + val includeUserHabits: Flow = + dataStore.data.map { preferences -> preferences[Keys.INCLUDE_USER_HABITS] ?: true } - val kimiApiKey: Flow = getApiKey(AiProvider.KIMI) - val kimiModel: Flow = getModel(AiProvider.KIMI) - val kimiSystemPrompt: Flow = getSystemPrompt(AiProvider.KIMI) + // ---- Local ML Model settings ---- - val glmApiKey: Flow = getApiKey(AiProvider.GLM) - val glmModel: Flow = getModel(AiProvider.GLM) - val glmSystemPrompt: Flow = getSystemPrompt(AiProvider.GLM) + val localMlEnabled: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_ENABLED] ?: false } - val openaiApiKey: Flow = getApiKey(AiProvider.OPENAI) - val openaiModel: Flow = getModel(AiProvider.OPENAI) - val openaiSystemPrompt: Flow = getSystemPrompt(AiProvider.OPENAI) + val localMlActiveModelId: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_ACTIVE_MODEL_ID] ?: "" } - val openrouterApiKey: Flow = getApiKey(AiProvider.OPENROUTER) - val openrouterModel: Flow = getModel(AiProvider.OPENROUTER) - val openrouterSystemPrompt: Flow = getSystemPrompt(AiProvider.OPENROUTER) + val localMlFallbackToRemote: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_FALLBACK_TO_REMOTE] ?: true } - val aiProvider: Flow = - dataStore.data.map { preferences -> preferences[Keys.AI_PROVIDER] ?: "GEMINI" } + val localMlUseGpu: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_USE_GPU] ?: false } - val isSafeTokenLimitEnabled: Flow = - dataStore.data.map { preferences -> preferences[Keys.SAFE_TOKEN_LIMIT] ?: true } + val localMlContextSize: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_CONTEXT_SIZE] ?: DEFAULT_LOCAL_MODEL_CONTEXT_SIZE } + + val localMlOllamaUrl: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_OLLAMA_URL] ?: "https://ollama.ai/api" } + + val localMlHfToken: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_HF_TOKEN] ?: "" } + + val aiTemperature: Flow = + dataStore.data.map { it[Keys.AI_TEMPERATURE] ?: 70 } + + val aiMaxTokens: Flow = + dataStore.data.map { it[Keys.AI_MAX_TOKENS] ?: 2048 } + + val aiEnableStreaming: Flow = + dataStore.data.map { it[Keys.AI_ENABLE_STREAMING] ?: true } + + val aiIncludeContext: Flow = + dataStore.data.map { it[Keys.AI_INCLUDE_CONTEXT] ?: true } + + val aiTopK: Flow = + dataStore.data.map { it[Keys.AI_TOP_K] ?: DEFAULT_TOP_K } + + val aiTopP: Flow = + dataStore.data.map { it[Keys.AI_TOP_P] ?: DEFAULT_TOP_P } + + val aiRepetitionPenalty: Flow = + dataStore.data.map { it[Keys.AI_REPETITION_PENALTY] ?: DEFAULT_REPETITION_PENALTY } + + val aiFrequencyPenalty: Flow = + dataStore.data.map { it[Keys.AI_FREQUENCY_PENALTY] ?: DEFAULT_FREQUENCY_PENALTY } + + val aiPresencePenalty: Flow = + dataStore.data.map { it[Keys.AI_PRESENCE_PENALTY] ?: DEFAULT_PRESENCE_PENALTY } + + // ---- Granular behavioral telemetry ---- + + val telemetryIncludeSkipCount: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_SKIP_COUNT] ?: false } + + val telemetryIncludeCompletionRate: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_COMPLETION_RATE] ?: false } + + val telemetryIncludeSessionDuration: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_SESSION_DURATION] ?: false } + + val telemetryIncludeTimeOfDay: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_TIME_OF_DAY] ?: false } + + val telemetryIncludeGenreAffinity: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_GENRE_AFFINITY] ?: false } + + val telemetryIncludeArtistAffinity: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_ARTIST_AFFINITY] ?: false } + + val telemetryIncludeReplayCount: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_REPLAY_COUNT] ?: false } + + val telemetryIncludeQueuePatterns: Flow = + dataStore.data.map { it[Keys.TELEMETRY_INCLUDE_QUEUE_PATTERNS] ?: false } + + // ---- AI Cache settings ---- + + val aiCacheEnabled: Flow = + dataStore.data.map { it[Keys.AI_CACHE_ENABLED] ?: true } + + val aiCacheMaxEntries: Flow = + dataStore.data.map { it[Keys.AI_CACHE_MAX_ENTRIES] ?: DEFAULT_CACHE_MAX_ENTRIES } + + val aiCacheTtlHours: Flow = + dataStore.data.map { it[Keys.AI_CACHE_TTL_HOURS] ?: DEFAULT_CACHE_TTL_HOURS } + + val aiCacheLastClearTs: Flow = + dataStore.data.map { it[Keys.AI_CACHE_LAST_CLEAR_TS] ?: 0L } + + // ---- Backup settings ---- + + val aiBackupIncludeUsageLogs: Flow = + dataStore.data.map { it[Keys.AI_BACKUP_INCLUDE_USAGE_LOGS] ?: true } + + val aiBackupIncludeCache: Flow = + dataStore.data.map { it[Keys.AI_BACKUP_INCLUDE_CACHE] ?: false } + + val aiBackupAutoExport: Flow = + dataStore.data.map { it[Keys.AI_BACKUP_AUTO_EXPORT] ?: false } + + val aiBackupLastExportTs: Flow = + dataStore.data.map { it[Keys.AI_BACKUP_LAST_EXPORT_TS] ?: 0L } + + val localModelDownloadTimeoutMs: Flow = + dataStore.data.map { it[Keys.LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS] ?: DEFAULT_LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS.toLong() } + + val localMlSelectedModelId: Flow = + dataStore.data.map { it[Keys.LOCAL_ML_SELECTED_MODEL_ID] ?: "" } + + val aiUsageTotalInputTokens: Flow = + dataStore.data.map { it[Keys.AI_USAGE_TOTAL_INPUT_TOKENS] ?: 0L } + + val aiUsageTotalOutputTokens: Flow = + dataStore.data.map { it[Keys.AI_USAGE_TOTAL_OUTPUT_TOKENS] ?: 0L } + + val aiUsageTotalApiCalls: Flow = + dataStore.data.map { it[Keys.AI_USAGE_TOTAL_API_CALLS] ?: 0L } + + val aiUsageEstimatedCost: Flow = + dataStore.data.map { it[Keys.AI_USAGE_ESTIMATED_COST] ?: "0.00" } + + fun getProviderTimeout(provider: AiProvider): Flow = + dataStore.data.map { it[Keys.getProviderTimeout(provider)] ?: 60000L } + + fun getPerModelTemperature(modelName: String): Flow = + dataStore.data.map { it[Keys.getPerModelTemperature(modelName)] } + + fun getPerModelMaxTokens(modelName: String): Flow = + dataStore.data.map { it[Keys.getPerModelMaxTokens(modelName)] } + + // ---- Mutators ---- suspend fun setAiProvider(provider: String) { - dataStore.edit { preferences -> preferences[Keys.AI_PROVIDER] = provider } + dataStore.edit { it[Keys.AI_PROVIDER] = provider } } suspend fun setSafeTokenLimitEnabled(enabled: Boolean) { - dataStore.edit { preferences -> preferences[Keys.SAFE_TOKEN_LIMIT] = enabled } + dataStore.edit { it[Keys.SAFE_TOKEN_LIMIT] = enabled } + } + + suspend fun setMaxSongsForContext(maxSongs: Int) { + dataStore.edit { it[Keys.MAX_SONGS_FOR_CONTEXT] = maxSongs.coerceIn(MIN_SONGS_FOR_CONTEXT, MAX_SONGS_FOR_CONTEXT) } + } + + suspend fun setIncludeLikedSongs(include: Boolean) { + dataStore.edit { it[Keys.INCLUDE_LIKED_SONGS] = include } + } + + suspend fun setIncludeDailyMixHistory(include: Boolean) { + dataStore.edit { it[Keys.INCLUDE_DAILY_MIX_HISTORY] = include } + } + + suspend fun setIncludeUserHabits(include: Boolean) { + dataStore.edit { it[Keys.INCLUDE_USER_HABITS] = include } + } + + // Local ML mutators + suspend fun setLocalMlEnabled(enabled: Boolean) { dataStore.edit { it[Keys.LOCAL_ML_ENABLED] = enabled } } + suspend fun setLocalMlActiveModelId(id: String) { dataStore.edit { it[Keys.LOCAL_ML_ACTIVE_MODEL_ID] = id } } + suspend fun setLocalMlFallbackToRemote(fallback: Boolean) { dataStore.edit { it[Keys.LOCAL_ML_FALLBACK_TO_REMOTE] = fallback } } + suspend fun setLocalMlUseGpu(useGpu: Boolean) { dataStore.edit { it[Keys.LOCAL_ML_USE_GPU] = useGpu } } + suspend fun setLocalMlContextSize(size: Int) { dataStore.edit { it[Keys.LOCAL_ML_CONTEXT_SIZE] = size } } + suspend fun setLocalMlOllamaUrl(url: String) { dataStore.edit { it[Keys.LOCAL_ML_OLLAMA_URL] = url } } + suspend fun setLocalMlHfToken(token: String) { dataStore.edit { it[Keys.LOCAL_ML_HF_TOKEN] = token } } + + suspend fun setAiTemperature(value: Int) { dataStore.edit { it[Keys.AI_TEMPERATURE] = value.coerceIn(1, 200) } } + suspend fun setAiMaxTokens(value: Int) { dataStore.edit { it[Keys.AI_MAX_TOKENS] = value.coerceIn(128, 16000) } } + suspend fun setAiEnableStreaming(enabled: Boolean) { dataStore.edit { it[Keys.AI_ENABLE_STREAMING] = enabled } } + suspend fun setAiIncludeContext(enabled: Boolean) { dataStore.edit { it[Keys.AI_INCLUDE_CONTEXT] = enabled } } + + suspend fun setAiTopK(value: Int) { dataStore.edit { it[Keys.AI_TOP_K] = value.coerceIn(1, 100) } } + suspend fun setAiTopP(value: Int) { dataStore.edit { it[Keys.AI_TOP_P] = value.coerceIn(1, 100) } } + suspend fun setAiRepetitionPenalty(value: Int) { dataStore.edit { it[Keys.AI_REPETITION_PENALTY] = value.coerceIn(100, 200) } } + suspend fun setAiFrequencyPenalty(value: Int) { dataStore.edit { it[Keys.AI_FREQUENCY_PENALTY] = value.coerceIn(-200, 200) } } + suspend fun setAiPresencePenalty(value: Int) { dataStore.edit { it[Keys.AI_PRESENCE_PENALTY] = value.coerceIn(-200, 200) } } + + // Telemetry mutators + suspend fun setTelemetryIncludeSkipCount(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_SKIP_COUNT] = v } } + suspend fun setTelemetryIncludeCompletionRate(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_COMPLETION_RATE] = v } } + suspend fun setTelemetryIncludeSessionDuration(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_SESSION_DURATION] = v } } + suspend fun setTelemetryIncludeTimeOfDay(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_TIME_OF_DAY] = v } } + suspend fun setTelemetryIncludeGenreAffinity(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_GENRE_AFFINITY] = v } } + suspend fun setTelemetryIncludeArtistAffinity(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_ARTIST_AFFINITY] = v } } + suspend fun setTelemetryIncludeReplayCount(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_REPLAY_COUNT] = v } } + suspend fun setTelemetryIncludeQueuePatterns(v: Boolean) { dataStore.edit { it[Keys.TELEMETRY_INCLUDE_QUEUE_PATTERNS] = v } } + + // Cache mutators + suspend fun setAiCacheEnabled(v: Boolean) { dataStore.edit { it[Keys.AI_CACHE_ENABLED] = v } } + suspend fun setAiCacheMaxEntries(v: Int) { dataStore.edit { it[Keys.AI_CACHE_MAX_ENTRIES] = v.coerceIn(MIN_CACHE_MAX_ENTRIES, MAX_CACHE_MAX_ENTRIES) } } + suspend fun setAiCacheTtlHours(v: Int) { dataStore.edit { it[Keys.AI_CACHE_TTL_HOURS] = v.coerceIn(MIN_CACHE_TTL_HOURS, MAX_CACHE_TTL_HOURS) } } + suspend fun recordAiCacheCleared() { dataStore.edit { it[Keys.AI_CACHE_LAST_CLEAR_TS] = System.currentTimeMillis() } } + + // Backup mutators + suspend fun setAiBackupIncludeUsageLogs(v: Boolean) { dataStore.edit { it[Keys.AI_BACKUP_INCLUDE_USAGE_LOGS] = v } } + suspend fun setAiBackupIncludeCache(v: Boolean) { dataStore.edit { it[Keys.AI_BACKUP_INCLUDE_CACHE] = v } } + suspend fun setAiBackupAutoExport(v: Boolean) { dataStore.edit { it[Keys.AI_BACKUP_AUTO_EXPORT] = v } } + suspend fun recordAiBackupExport() { dataStore.edit { it[Keys.AI_BACKUP_LAST_EXPORT_TS] = System.currentTimeMillis() } } + + suspend fun setLocalModelDownloadTimeoutMs(timeoutMs: Long) { + dataStore.edit { it[Keys.LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS] = timeoutMs.coerceIn(10000, 3600000) } + } + + suspend fun setLocalMlSelectedModelId(modelId: String) { + dataStore.edit { it[Keys.LOCAL_ML_SELECTED_MODEL_ID] = modelId } + } + + suspend fun setAiUsageTotalInputTokens(tokens: Long) { + dataStore.edit { it[Keys.AI_USAGE_TOTAL_INPUT_TOKENS] = tokens } + } + + suspend fun setAiUsageTotalOutputTokens(tokens: Long) { + dataStore.edit { it[Keys.AI_USAGE_TOTAL_OUTPUT_TOKENS] = tokens } + } + + suspend fun setAiUsageTotalApiCalls(calls: Long) { + dataStore.edit { it[Keys.AI_USAGE_TOTAL_API_CALLS] = calls } + } + + suspend fun incrementAiUsageMetrics(inputTokens: Int, outputTokens: Int) { + dataStore.edit { prefs -> + val currentInput = prefs[Keys.AI_USAGE_TOTAL_INPUT_TOKENS] ?: 0L + val currentOutput = prefs[Keys.AI_USAGE_TOTAL_OUTPUT_TOKENS] ?: 0L + val currentCalls = prefs[Keys.AI_USAGE_TOTAL_API_CALLS] ?: 0L + prefs[Keys.AI_USAGE_TOTAL_INPUT_TOKENS] = currentInput + inputTokens + prefs[Keys.AI_USAGE_TOTAL_OUTPUT_TOKENS] = currentOutput + outputTokens + prefs[Keys.AI_USAGE_TOTAL_API_CALLS] = currentCalls + 1 + } + } + + suspend fun setAiUsageEstimatedCost(cost: String) { + dataStore.edit { it[Keys.AI_USAGE_ESTIMATED_COST] = cost } + } + + suspend fun getAiProviderOnce(): String = aiProvider.first() + suspend fun getAiTemperatureOnce(): Int = aiTemperature.first() + suspend fun getAiMaxTokensOnce(): Int = aiMaxTokens.first() + suspend fun getAiEnableStreamingOnce(): Boolean = aiEnableStreaming.first() + suspend fun getAiIncludeContextOnce(): Boolean = aiIncludeContext.first() + suspend fun getAiTopKOnce(): Int = aiTopK.first() + suspend fun getAiTopPOnce(): Int = aiTopP.first() + suspend fun getAiRepetitionPenaltyOnce(): Int = aiRepetitionPenalty.first() + suspend fun getAiFrequencyPenaltyOnce(): Int = aiFrequencyPenalty.first() + suspend fun getAiPresencePenaltyOnce(): Int = aiPresencePenalty.first() + suspend fun getMaxSongsForContextOnce(): Int = maxSongsForContext.first() + suspend fun getIncludeLikedSongsOnce(): Boolean = includeLikedSongs.first() + suspend fun getIncludeDailyMixHistoryOnce(): Boolean = includeDailyMixHistory.first() + suspend fun getIncludeUserHabitsOnce(): Boolean = includeUserHabits.first() + suspend fun getAiCacheEnabledOnce(): Boolean = aiCacheEnabled.first() + suspend fun getAiCacheMaxEntriesOnce(): Int = aiCacheMaxEntries.first() + suspend fun getAiCacheTtlHoursOnce(): Int = aiCacheTtlHours.first() + suspend fun getLocalMlEnabledOnce(): Boolean = localMlEnabled.first() + suspend fun getLocalMlUseGpuOnce(): Boolean = localMlUseGpu.first() + suspend fun getLocalMlFallbackToRemoteOnce(): Boolean = localMlFallbackToRemote.first() + suspend fun getLocalMlContextSizeOnce(): Int = localMlContextSize.first() + suspend fun getSafeTokenLimitOnce(): Boolean = isSafeTokenLimitEnabled.first() + + suspend fun clearAiUsageMetrics() { + dataStore.edit { prefs -> + prefs[Keys.AI_USAGE_TOTAL_INPUT_TOKENS] = 0L + prefs[Keys.AI_USAGE_TOTAL_OUTPUT_TOKENS] = 0L + prefs[Keys.AI_USAGE_TOTAL_API_CALLS] = 0L + prefs[Keys.AI_USAGE_ESTIMATED_COST] = "0.00" + } + } + + suspend fun setProviderTimeout(provider: AiProvider, timeoutMs: Long) { + dataStore.edit { it[Keys.getProviderTimeout(provider)] = timeoutMs.coerceIn(5000, 300000) } + } + + suspend fun setPerModelTemperature(modelName: String, temperature: Int) { + dataStore.edit { it[Keys.getPerModelTemperature(modelName)] = temperature.coerceIn(1, 200) } + } + + suspend fun clearPerModelTemperature(modelName: String) { + dataStore.edit { it.remove(Keys.getPerModelTemperature(modelName)) } + } + + suspend fun setPerModelMaxTokens(modelName: String, maxTokens: Int) { + dataStore.edit { it[Keys.getPerModelMaxTokens(modelName)] = maxTokens.coerceIn(128, 16000) } + } + + suspend fun clearPerModelMaxTokens(modelName: String) { + dataStore.edit { it.remove(Keys.getPerModelMaxTokens(modelName)) } } } + 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 276c57a42..b7dbadcf1 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 @@ -239,6 +239,30 @@ constructor( // ReplayGain val REPLAYGAIN_ENABLED = booleanPreferencesKey("replaygain_enabled") val REPLAYGAIN_USE_ALBUM_GAIN = booleanPreferencesKey("replaygain_use_album_gain") + + // AI Developer Options + val AI_DEVELOPER_MODE = booleanPreferencesKey("ai_developer_mode") + val AI_TEMPERATURE = intPreferencesKey("ai_temperature") + val AI_MAX_TOKENS = intPreferencesKey("ai_max_tokens") + val AI_STREAMING_ENABLED = booleanPreferencesKey("ai_streaming_enabled") + val AI_CONTEXT_ENABLED = booleanPreferencesKey("ai_context_enabled") + val AI_CACHE_ENABLED = booleanPreferencesKey("ai_cache_enabled") + val AI_CACHE_SIZE_MB = intPreferencesKey("ai_cache_size_mb") + val AI_CACHE_TTL_HOURS = intPreferencesKey("ai_cache_ttl_hours") + val AI_DEBUG_LOGGING = booleanPreferencesKey("ai_debug_logging") + val AI_SHOW_TOKEN_ESTIMATES = booleanPreferencesKey("ai_show_token_estimates") + val AI_CUSTOM_SYSTEM_PROMPT = stringPreferencesKey("ai_custom_system_prompt") + val AI_PROVIDER_FALLBACK_CHAIN = stringPreferencesKey("ai_provider_fallback_chain") + val AI_TIMEOUT_SECONDS = intPreferencesKey("ai_timeout_seconds") + val AI_RETRY_COUNT = intPreferencesKey("ai_retry_count") + val AI_COOLDOWN_MINUTES = intPreferencesKey("ai_cooldown_minutes") + val AI_TELEMETRY_ENABLED = booleanPreferencesKey("ai_telemetry_enabled") + val AI_TELEMETRY_SKIP_RATE = booleanPreferencesKey("ai_telemetry_skip_rate") + val AI_TELEMETRY_COMPLETION_RATE = booleanPreferencesKey("ai_telemetry_completion_rate") + val AI_TELEMETRY_SESSION_DURATION = booleanPreferencesKey("ai_telemetry_session_duration") + val AI_TELEMETRY_TIME_OF_DAY = booleanPreferencesKey("ai_telemetry_time_of_day") + val AI_TELEMETRY_GENRE_AFFINITY = booleanPreferencesKey("ai_telemetry_genre_affinity") + val AI_TELEMETRY_ARTIST_AFFINITY = booleanPreferencesKey("ai_telemetry_artist_affinity") } val appRebrandDialogShownFlow: Flow = @@ -793,6 +817,252 @@ constructor( // ===== End ReplayGain ===== + // ===== AI Developer Options ===== + + val aiDeveloperModeFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_DEVELOPER_MODE] ?: false + } + + val aiTemperatureFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TEMPERATURE] ?: 70 + } + + val aiMaxTokensFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_MAX_TOKENS] ?: 2048 + } + + val aiStreamingEnabledFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_STREAMING_ENABLED] ?: true + } + + val aiContextEnabledFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_CONTEXT_ENABLED] ?: true + } + + val aiCacheEnabledFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_CACHE_ENABLED] ?: true + } + + val aiCacheSizeMbFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_CACHE_SIZE_MB] ?: 50 + } + + val aiCacheTtlHoursFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_CACHE_TTL_HOURS] ?: 24 + } + + val aiDebugLoggingFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_DEBUG_LOGGING] ?: false + } + + val aiShowTokenEstimatesFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_SHOW_TOKEN_ESTIMATES] ?: false + } + + val aiCustomSystemPromptFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_CUSTOM_SYSTEM_PROMPT] ?: "" + } + + val aiProviderFallbackChainFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_PROVIDER_FALLBACK_CHAIN] ?: "GEMINI,OPENAI,ANTHROPIC" + } + + val aiTimeoutSecondsFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TIMEOUT_SECONDS] ?: 60 + } + + val aiRetryCountFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_RETRY_COUNT] ?: 2 + } + + val aiCooldownMinutesFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_COOLDOWN_MINUTES] ?: 5 + } + + val aiTelemetryEnabledFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_ENABLED] ?: false + } + + val aiTelemetrySkipRateFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_SKIP_RATE] ?: false + } + + val aiTelemetryCompletionRateFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_COMPLETION_RATE] ?: false + } + + val aiTelemetrySessionDurationFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_SESSION_DURATION] ?: false + } + + val aiTelemetryTimeOfDayFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_TIME_OF_DAY] ?: false + } + + val aiTelemetryGenreAffinityFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_GENRE_AFFINITY] ?: false + } + + val aiTelemetryArtistAffinityFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_ARTIST_AFFINITY] ?: false + } + + suspend fun setAiDeveloperMode(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_DEVELOPER_MODE] = enabled + } + } + + suspend fun setAiTemperature(value: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TEMPERATURE] = value.coerceIn(1, 200) + } + } + + suspend fun setAiMaxTokens(value: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_MAX_TOKENS] = value.coerceIn(128, 16000) + } + } + + suspend fun setAiStreamingEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_STREAMING_ENABLED] = enabled + } + } + + suspend fun setAiContextEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_CONTEXT_ENABLED] = enabled + } + } + + suspend fun setAiCacheEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_CACHE_ENABLED] = enabled + } + } + + suspend fun setAiCacheSizeMb(sizeMb: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_CACHE_SIZE_MB] = sizeMb.coerceIn(10, 500) + } + } + + suspend fun setAiCacheTtlHours(hours: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_CACHE_TTL_HOURS] = hours.coerceIn(1, 168) + } + } + + suspend fun setAiDebugLogging(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_DEBUG_LOGGING] = enabled + } + } + + suspend fun setAiShowTokenEstimates(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_SHOW_TOKEN_ESTIMATES] = enabled + } + } + + suspend fun setAiCustomSystemPrompt(prompt: String) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_CUSTOM_SYSTEM_PROMPT] = prompt + } + } + + suspend fun setAiProviderFallbackChain(chain: String) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_PROVIDER_FALLBACK_CHAIN] = chain + } + } + + suspend fun setAiTimeoutSeconds(seconds: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TIMEOUT_SECONDS] = seconds.coerceIn(10, 300) + } + } + + suspend fun setAiRetryCount(count: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_RETRY_COUNT] = count.coerceIn(0, 5) + } + } + + suspend fun setAiCooldownMinutes(minutes: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_COOLDOWN_MINUTES] = minutes.coerceIn(1, 30) + } + } + + suspend fun setAiTelemetryEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_ENABLED] = enabled + } + } + + suspend fun setAiTelemetrySkipRate(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_SKIP_RATE] = enabled + } + } + + suspend fun setAiTelemetryCompletionRate(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_COMPLETION_RATE] = enabled + } + } + + suspend fun setAiTelemetrySessionDuration(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_SESSION_DURATION] = enabled + } + } + + suspend fun setAiTelemetryTimeOfDay(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_TIME_OF_DAY] = enabled + } + } + + suspend fun setAiTelemetryGenreAffinity(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_GENRE_AFFINITY] = enabled + } + } + + suspend fun setAiTelemetryArtistAffinity(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AI_TELEMETRY_ARTIST_AFFINITY] = enabled + } + } + + // ===== End AI Developer Options ===== + val allowedDirectoriesFlow: Flow> = dataStore.data.map { preferences -> preferences[PreferencesKeys.ALLOWED_DIRECTORIES] ?: emptySet() diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt index d387df3d6..659310524 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.os.SystemClock -import android.util.Log import androidx.core.net.toUri import androidx.media3.common.MimeTypes import androidx.media3.decoder.ffmpeg.FfmpegLibrary @@ -21,7 +20,9 @@ import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.api.PendingResult import com.google.android.gms.common.images.WebImage +import android.util.Log import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.network.NetworkTimeouts import com.theveloper.pixelplay.data.service.cast.CastAudioMimeUtils import com.theveloper.pixelplay.data.service.cast.IsoBmffAudioCodecDetector import com.theveloper.pixelplay.data.service.http.CastSessionSecurity @@ -46,7 +47,7 @@ class CastPlayer( } private val remoteMediaClient: RemoteMediaClient? = castSession.remoteMediaClient - private val queueLoadTimeoutMs = 25000L + private val queueLoadTimeoutMs = NetworkTimeouts.CAST_QUEUE_LOAD_MS private val commandTimeoutMs = 3500L private val commandRetryDelayMs = 220L private val minCommandSpacingMs = 120L @@ -145,18 +146,12 @@ class CastPlayer( if (!result.status.isSuccess && retriesLeft > 0) { val isInvalidRequest = result.status.statusMessage ?.contains("Invalid Request", ignoreCase = true) == true - if (isInvalidRequest) { - Log.e( - "PX_CAST_CMD", - "Invalid Request command=${queuedCommand.name} status=${result.status.statusCode} msg=${result.status.statusMessage}" - ) - } if (isInvalidRequest) { Timber.w( - "Cast command invalid request: %s (%s/%d)", + "Cast InvalidRequest command=%s status=%d msg=%s", queuedCommand.name, - result.status.statusMessage, - result.status.statusCode + result.status.statusCode, + result.status.statusMessage ) complete(requestStatus = true) return@setResultCallback @@ -175,10 +170,6 @@ class CastPlayer( } if (!result.status.isSuccess) { - Log.e( - "PX_CAST_CMD", - "Command failed command=${queuedCommand.name} status=${result.status.statusCode} msg=${result.status.statusMessage}" - ) Timber.w( "Cast command failed: %s (%s/%d)", queuedCommand.name, @@ -325,9 +316,9 @@ class CastPlayer( val alacDecoderAvailable = isAlacTranscodeSupported() val forcedMime = if (alacDecoderAvailable) "audio/aac" else "audio/mp4" forcedMimeBySongId[song.id] = forcedMime - Log.i( - "PX_CAST_QLOAD", - "alac_probe songId=${song.id} rawCodec=audio/alac forcedMime=$forcedMime decoderAvailable=$alacDecoderAvailable nonce=$queueLoadNonce" + Timber.tag(castLogTag).i( + "alac_probe songId=%s rawCodec=audio/alac forcedMime=%s decoderAvailable=%s nonce=%s", + song.id, forcedMime, alacDecoderAvailable, queueLoadNonce ) continue } @@ -336,9 +327,9 @@ class CastPlayer( val flacDecoderAvailable = isFlacTranscodeSupported() val forcedMime = if (flacDecoderAvailable) "audio/aac" else "audio/flac" forcedMimeBySongId[song.id] = forcedMime - Log.i( - "PX_CAST_QLOAD", - "flac_probe songId=${song.id} rawCodec=audio/flac forcedMime=$forcedMime decoderAvailable=$flacDecoderAvailable nonce=$queueLoadNonce" + Timber.tag(castLogTag).i( + "flac_probe songId=%s rawCodec=audio/flac forcedMime=%s decoderAvailable=%s nonce=%s", + song.id, forcedMime, flacDecoderAvailable, queueLoadNonce ) continue } @@ -352,11 +343,9 @@ class CastPlayer( if (forcedMime != null) { forcedMimeBySongId[song.id] = forcedMime } - val resolverMime = contentResolver - ?.let { resolver -> runCatching { resolver.getType(song.contentUriString.toUri()) }.getOrNull() } - Log.i( - "PX_CAST_QLOAD", - "start_probe songId=${song.id} songMime=${song.mimeType} resolverMime=$resolverMime rawExtractorMime=$rawExtractorMime retrieverMime=$retrieverMime signatureMime=$signatureMime forcedMime=$forcedMime nonce=$queueLoadNonce" + Timber.tag(castLogTag).i( + "start_probe songId=%s songMime=%s rawExtractorMime=%s retrieverMime=%s signatureMime=%s forcedMime=%s nonce=%s", + song.id, song.mimeType, rawExtractorMime, retrieverMime, signatureMime, forcedMime, queueLoadNonce ) } } @@ -381,10 +370,6 @@ class CastPlayer( autoPlay, serverAddress ) - Log.i( - "PX_CAST_QLOAD", - "start size=${songs.size} startIndex=$safeStartIndex songId=${startSong?.id} autoPlay=$autoPlay nonce=$queueLoadNonce" - ) logQueueDiagnostics( songs = songs, startIndex = safeStartIndex, @@ -419,10 +404,6 @@ class CastPlayer( result.status.statusCode, result.status.statusMessage ) - Log.i( - "PX_CAST_QLOAD", - "success status=${result.status.statusCode} msg=${result.status.statusMessage}" - ) if (!autoPlay) { // queueLoad typically starts playback by default; explicitly pause when caller requests no autoplay. client.pause() @@ -439,10 +420,6 @@ class CastPlayer( startSong?.id, songs.size ) - Log.e( - "PX_CAST_QLOAD", - "failed status=${result.status.statusCode} msg=${result.status.statusMessage} songId=${startSong?.id} size=${songs.size}" - ) onComplete(false, failureDetail) } } 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..d33d7fa4c 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 orchestrator: AiHandler, private val notificationManager: AiNotificationManager, private val musicRepository: MusicRepository, private val digestGenerator: UserProfileDigestGenerator, @@ -72,7 +72,8 @@ class AiWorker @AssistedInject constructor( type == AiSystemPromptType.PERSONA) { val allSongs = musicRepository.getAllSongsOnce() val isSafe = preferencesRepo.isSafeTokenLimitEnabled.first() - digestGenerator.generateDigest(allSongs, isSafe) + val maxContext = preferencesRepo.getMaxSongsForContextOnce() + digestGenerator.generateDigest(allSongs, isSafe, maxContext) } else "" val result = orchestrator.generateContent( 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 25dc0b549..772a49673 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 @@ -372,13 +372,14 @@ constructor( ) } totalScannedCount += idBatch.size - Log.d( - TAG, - "LRC Scan: Processed batch of ${idBatch.size}, total assigned so far: $batchScannedCount" + Timber.tag(TAG).d( + "LRC Scan: Processed batch of %d, total assigned so far: %d", + idBatch.size, + batchScannedCount ) } - Log.i(TAG, "LRC Scan finished for $totalToScan songs.") + Timber.tag(TAG).i("LRC Scan finished for %d songs.", totalToScan) } // Clean orphaned album art cache files @@ -414,7 +415,7 @@ constructor( if (hasTelegramChannels) { syncTelegramData() } else { - Log.d(TAG, "Skipping Telegram sync — no channels configured.") + Timber.tag(TAG).d("Skipping Telegram sync — no channels configured.") } // syncNeteaseData already has an internal isEmpty guard; a lightweight @@ -422,13 +423,13 @@ constructor( if (neteaseCount > 0) { syncNeteaseData() } else { - Log.d(TAG, "Skipping Netease sync — no songs in local cache.") + Timber.tag(TAG).d("Skipping Netease sync — no songs in local cache.") } if (navidromeRepository.isLoggedIn) { syncNavidromeData() } else { - Log.d(TAG, "Skipping Navidrome sync — not logged in.") + Timber.tag(TAG).d("Skipping Navidrome sync — not logged in.") } // Recalculate total @@ -436,7 +437,7 @@ constructor( Result.success(workDataOf(OUTPUT_TOTAL_SONGS to finalTotalSongs.toLong())) } catch (e: Exception) { - Log.e(TAG, "Error during MediaStore synchronization", e) + Timber.tag(TAG).e(e, "Error during MediaStore synchronization") Result.failure() } finally { Trace.endSection() // End SyncWorker.doWork @@ -628,7 +629,11 @@ constructor( val now = System.currentTimeMillis() val cacheAge = now - genreMapCacheTimestamp if (!forceRefresh && genreMapCache.isNotEmpty() && cacheAge < GENRE_CACHE_TTL_MS) { - Log.d(TAG, "Using cached genre map (${genreMapCache.size} entries, age: ${cacheAge/1000}s)") + Timber.tag(TAG).d( + "Using cached genre map (%d entries, age: %ds)", + genreMapCache.size, + cacheAge / 1000 + ) return@coroutineScope genreMapCache } @@ -705,14 +710,14 @@ constructor( }.awaitAll() } catch (e: Exception) { - Log.e(TAG, "Error fetching genre map", e) + Timber.tag(TAG).e(e, "Error fetching genre map") } // Update cache if (genreMap.isNotEmpty()) { genreMapCache = genreMap.toMap() genreMapCacheTimestamp = System.currentTimeMillis() - Log.d(TAG, "Genre map cache updated with ${genreMap.size} entries") + Timber.tag(TAG).d("Genre map cache updated with %d entries", genreMap.size) } genreMap @@ -893,7 +898,7 @@ constructor( } if (rawDataList.isEmpty()) { - Log.i(TAG, "MediaStore cursor produced 0 raw songs after directory filtering") + Timber.tag(TAG).i("MediaStore cursor produced 0 raw songs after directory filtering") Trace.endSection() return emptyList() } @@ -928,9 +933,11 @@ constructor( rawDataList.clear() val totalCount = songsToProcess.size - Log.i( - TAG, - "MediaStore raw=$rawSongCount, songsToProcess=$totalCount, isRebuild=$isRebuild" + Timber.tag(TAG).i( + "MediaStore raw=%d, songsToProcess=%d, isRebuild=%s", + rawSongCount, + totalCount, + isRebuild ) if (totalCount == 0) { Trace.endSection() @@ -1087,7 +1094,7 @@ constructor( } } } catch (e: Exception) { - Log.w(TAG, "Failed to read metadata via TagLib for ${raw.filePath}", e) + Timber.tag(TAG).w(e, "Failed to read metadata via TagLib for %s", raw.filePath) } } } @@ -1135,7 +1142,7 @@ constructor( // Get all file paths currently in MediaStore val mediaStorePaths = fetchMediaStoreFilePaths() - Log.d(TAG, "MediaStore has ${mediaStorePaths.size} known files") + Timber.tag(TAG).d("MediaStore has %d known files", mediaStorePaths.size) val scanRoots = collectPreferredScanRoots( @@ -1146,7 +1153,7 @@ constructor( ) if (scanRoots.isEmpty()) { - Log.d(TAG, "No eligible roots found for media scan") + Timber.tag(TAG).d("No eligible roots found for media scan") return@withContext } @@ -1195,11 +1202,11 @@ constructor( } if (newFilesToScan.isEmpty()) { - Log.d(TAG, "No new audio files found - MediaStore is up to date") + Timber.tag(TAG).d("No new audio files found - MediaStore is up to date") return@withContext } - Log.i(TAG, "Found ${newFilesToScan.size} NEW audio files to scan") + Timber.tag(TAG).i("Found %d NEW audio files to scan", newFilesToScan.size) // Scan only the new files val latch = CountDownLatch(1) @@ -1219,9 +1226,9 @@ constructor( // Wait for scan to complete (max 15 seconds) val completed = latch.await(15, TimeUnit.SECONDS) if (!completed) { - Log.w(TAG, "Media scan timeout after scanning $scannedCount/${newFilesToScan.size} files") + Timber.tag(TAG).w("Media scan timeout after scanning %d/%d files", scannedCount, newFilesToScan.size) } else { - Log.i(TAG, "Media scan completed for ${newFilesToScan.size} new files") + Timber.tag(TAG).i("Media scan completed for %d new files", newFilesToScan.size) } } } @@ -1381,7 +1388,7 @@ constructor( fun invalidateGenreCache() { genreMapCache = emptyMap() genreMapCacheTimestamp = 0L - Log.d(TAG, "Genre cache invalidated") + Timber.tag(TAG).d("Genre cache invalidated") } fun startUpSyncWork(deepScan: Boolean = false) = @@ -1428,7 +1435,7 @@ constructor( // Logic to sync Telegram songs into main DB with Unified Library Support private suspend fun syncTelegramData() { - Log.i(TAG, "Syncing Telegram songs to main database (Unified Mode)...") + Timber.tag(TAG).i("Syncing Telegram songs to main database (Unified Mode)...") try { val telegramSongs = telegramDao.getAllTelegramSongs().first() val channels = telegramDao.getAllChannels().first().associateBy { it.chatId } @@ -1438,7 +1445,7 @@ constructor( if (existingUnifiedTelegramIds.isNotEmpty()) { musicDao.clearAllTelegramSongs() } - Log.d(TAG, "No Telegram songs to sync.") + Timber.tag(TAG).d("No Telegram songs to sync.") return } @@ -1635,14 +1642,14 @@ constructor( crossRefs = crossRefsToInsert, deletedSongIds = deletedUnifiedSongIds ) - Log.i(TAG, "Synced ${songsToInsert.size} Telegram songs with Unified Metadata.") + Timber.tag(TAG).i("Synced %d Telegram songs with Unified Metadata.", songsToInsert.size) } catch (e: Exception) { - Log.e(TAG, "Failed to sync Telegram data", e) + Timber.tag(TAG).e(e, "Failed to sync Telegram data") } } private suspend fun syncNeteaseData() { - Log.i(TAG, "Syncing Netease songs to main database (Unified Mode)...") + Timber.tag(TAG).i("Syncing Netease songs to main database (Unified Mode)...") try { val neteaseSongs = neteaseDao.getAllNeteaseSongsList() val existingUnifiedNeteaseIds = musicDao.getAllNeteaseSongIds() @@ -1651,7 +1658,7 @@ constructor( if (existingUnifiedNeteaseIds.isNotEmpty()) { musicDao.clearAllNeteaseSongs() } - Log.d(TAG, "No Netease songs to sync.") + Timber.tag(TAG).d("No Netease songs to sync.") return } @@ -1757,9 +1764,9 @@ constructor( crossRefs = crossRefsToInsert, deletedSongIds = deletedUnifiedSongIds ) - Log.i(TAG, "Synced ${songsToInsert.size} Netease songs with Unified Metadata.") + Timber.tag(TAG).i("Synced %d Netease songs with Unified Metadata.", songsToInsert.size) } catch (e: Exception) { - Log.e(TAG, "Failed to sync Netease data", e) + Timber.tag(TAG).e(e, "Failed to sync Netease data") } } @@ -1809,10 +1816,15 @@ constructor( val result = navidromeRepository.syncAllPlaylistsAndSongs() result.fold( onSuccess = { summary -> - Log.i(TAG, "Navidrome sync complete: ${summary.playlistCount} playlists, ${summary.syncedSongCount} songs synced (${summary.failedPlaylistCount} failed)") + Timber.tag(TAG).i( + "Navidrome sync complete: %d playlists, %d songs synced (%d failed)", + summary.playlistCount, + summary.syncedSongCount, + summary.failedPlaylistCount + ) }, onFailure = { e -> - Log.w(TAG, "Navidrome server sync failed, falling back to local cache sync", e) + Timber.tag(TAG).w(e, "Navidrome server sync failed, falling back to local cache sync") // Fallback: at least sync what we already have cached navidromeRepository.syncUnifiedLibrarySongsFromNavidrome() } diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index b86bd4a16..7f1d93599 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -164,7 +164,8 @@ object AppModule { PixelPlayDatabase.MIGRATION_37_38, PixelPlayDatabase.MIGRATION_38_39, PixelPlayDatabase.MIGRATION_39_40, - PixelPlayDatabase.MIGRATION_40_41 + PixelPlayDatabase.MIGRATION_40_41, + PixelPlayDatabase.MIGRATION_41_42 ) .addCallback(PixelPlayDatabase.createRuntimeArtifactsCallback()) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) diff --git a/app/src/main/java/com/theveloper/pixelplay/di/BackupModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/BackupModule.kt index c25857c6a..3211481b3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/BackupModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/BackupModule.kt @@ -17,6 +17,7 @@ import com.theveloper.pixelplay.data.backup.module.QuickFillModuleHandler import com.theveloper.pixelplay.data.backup.module.SearchHistoryModuleHandler import com.theveloper.pixelplay.data.backup.module.TransitionsModuleHandler import com.theveloper.pixelplay.data.backup.module.AiUsageBackupHandler +import com.theveloper.pixelplay.data.backup.module.AiContextBackupHandler import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -57,7 +58,8 @@ object BackupModule { quickFillHandler: QuickFillModuleHandler, artistImagesHandler: ArtistImagesModuleHandler, equalizerHandler: EqualizerModuleHandler, - aiUsageHandler: AiUsageBackupHandler + aiUsageHandler: AiUsageBackupHandler, + aiContextHandler: AiContextBackupHandler ): Map { return mapOf( BackupSection.PLAYLISTS to playlistsHandler, @@ -71,7 +73,8 @@ object BackupModule { BackupSection.QUICK_FILL to quickFillHandler, BackupSection.ARTIST_IMAGES to artistImagesHandler, BackupSection.EQUALIZER to equalizerHandler, - BackupSection.AI_USAGE_LOGS to aiUsageHandler + BackupSection.AI_USAGE_LOGS to aiUsageHandler, + BackupSection.AI_CONTEXT to aiContextHandler ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AiMetadataSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AiMetadataSheet.kt deleted file mode 100644 index 433ed3485..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AiMetadataSheet.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.theveloper.pixelplay.presentation.components - - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.theveloper.pixelplay.data.ai.SongMetadata -import com.theveloper.pixelplay.ui.theme.ExpTitleTypography -import com.theveloper.pixelplay.ui.theme.GoogleSansRounded -import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape -import androidx.compose.ui.res.stringResource -import com.theveloper.pixelplay.R -import androidx.compose.ui.text.style.TextOverflow - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun AiMetadataSheet( - onDismiss: () -> Unit, - onApply: (SongMetadata) -> Unit, - initialMetadata: SongMetadata?, - isGenerating: Boolean, - isSuccess: Boolean, - error: String?, - onRetry: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val colors = MaterialTheme.colorScheme - - var title by remember(initialMetadata) { mutableStateOf(initialMetadata?.title ?: "") } - var artist by remember(initialMetadata) { mutableStateOf(initialMetadata?.artist ?: "") } - var album by remember(initialMetadata) { mutableStateOf(initialMetadata?.album ?: "") } - var genre by remember(initialMetadata) { mutableStateOf(initialMetadata?.genre ?: "") } - - val smoothCornerShape = remember { - AbsoluteSmoothCornerShape( - cornerRadiusTL = 24.dp, - smoothnessAsPercentBL = 60, - cornerRadiusTR = 24.dp, - smoothnessAsPercentBR = 60, - cornerRadiusBL = 24.dp, - smoothnessAsPercentTL = 60, - cornerRadiusBR = 24.dp, - smoothnessAsPercentTR = 60 - ) - } - - val infiniteTransition = rememberInfiniteTransition(label = "ai_meta_animation") - val iconRotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(2500, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Restart - ), - label = "rotation" - ) - val iconScale by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 1.1f, - animationSpec = infiniteRepeatable( - animation = tween(1200, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "scale" - ) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = colors.surfaceContainerLow, - tonalElevation = 8.dp - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Surface( - modifier = Modifier - .size(56.dp) - .then( - if (isGenerating) Modifier.rotate(iconRotation).scale(iconScale) - else Modifier - ), - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = when { - isGenerating -> colors.primaryContainer - isSuccess -> Color(0xFF4CAF50).copy(alpha = 0.2f) - error != null -> colors.errorContainer - else -> colors.secondaryContainer - }, - tonalElevation = 4.dp - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = when { - isSuccess -> Icons.Rounded.Check - error != null -> Icons.Rounded.Close - else -> Icons.Rounded.AutoAwesome - }, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = when { - isGenerating -> colors.onPrimaryContainer - isSuccess -> Color(0xFF4CAF50) - error != null -> colors.onErrorContainer - else -> colors.onSecondaryContainer - } - ) - } - } - Column { - Text( - text = if (isSuccess) { - stringResource(R.string.ai_metadata_headline_success) - } else { - stringResource(R.string.ai_metadata_headline_default) - }, - style = ExpTitleTypography.headlineSmall.copy(fontWeight = FontWeight.ExtraBold), - color = colors.onSurface - ) - Text( - text = if (isGenerating) { - stringResource(R.string.ai_metadata_subtitle_generating) - } else { - stringResource(R.string.ai_metadata_subtitle_review) - }, - style = MaterialTheme.typography.bodyMedium, - fontFamily = GoogleSansRounded, - color = colors.onSurfaceVariant - ) - } - } - - if (isGenerating) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(AbsoluteSmoothCornerShape(3.dp, 60)), - color = colors.primary, - trackColor = colors.primaryContainer.copy(alpha = 0.3f) - ) - } - } - - // Editable Fields - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - MetadataField(stringResource(R.string.song_field_title), title) { title = it } - MetadataField(stringResource(R.string.song_field_artist), artist) { artist = it } - MetadataField(stringResource(R.string.song_field_album), album) { album = it } - MetadataField(stringResource(R.string.song_field_genre), genre) { genre = it } - } - - // Error Display & Retry - AnimatedVisibility(visible = error != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = colors.errorContainer, - shape = smoothCornerShape - ) { - Text( - text = error ?: "", - modifier = Modifier.padding(16.dp), - color = colors.onErrorContainer, - style = MaterialTheme.typography.bodySmall - ) - } - Button( - onClick = onRetry, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = smoothCornerShape, - colors = ButtonDefaults.buttonColors(containerColor = colors.error) - ) { - Text(stringResource(R.string.action_try_again), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - } - - // Action Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier.weight(1f).height(56.dp), - shape = smoothCornerShape - ) { - Icon(Icons.Rounded.Close, null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Button( - onClick = { - onApply(SongMetadata(title, artist, album, genre)) - }, - modifier = Modifier.weight(1.5f).height(56.dp), - shape = smoothCornerShape, - enabled = !isGenerating && (initialMetadata != null || isSuccess) - ) { - Icon(Icons.Rounded.Check, null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.action_apply_changes)) - } - } - } - } -} - -@Composable -private fun MetadataField( - label: String, - value: String, - onValueChange: (String) -> Unit -) { - val colors = MaterialTheme.colorScheme - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(label) }, - modifier = Modifier.fillMaxWidth(), - shape = AbsoluteSmoothCornerShape(16.dp, 60), - colors = TextFieldDefaults.colors( - focusedContainerColor = colors.surfaceContainer, - unfocusedContainerColor = colors.surfaceContainer, - focusedIndicatorColor = colors.primary.copy(alpha = 0.5f), - unfocusedIndicatorColor = Color.Transparent - ), - singleLine = true - ) -} 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 4f64571cd..08b4888e7 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 @@ -162,9 +162,6 @@ fun DailyMixSection( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt index 20b3c07b4..88cd00b54 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt @@ -15,7 +15,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -45,8 +44,6 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -59,11 +56,8 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.FlexibleBottomAppBar import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LargeExtendedFloatingActionButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumExtendedFloatingActionButton @@ -353,8 +347,10 @@ private fun CreateAiPlaylistContent( var selectedMood by rememberSaveable { mutableStateOf(null) } var selectedActivity by rememberSaveable { mutableStateOf(null) } var energyLevel by rememberSaveable { mutableIntStateOf(3) } + var tempoLevel by rememberSaveable { mutableIntStateOf(3) } var discoveryLevel by rememberSaveable { mutableIntStateOf(3) } var prioritizeFavorites by rememberSaveable { mutableStateOf(true) } + var focusRecent by rememberSaveable { mutableStateOf(false) } var avoidExplicit by rememberSaveable { mutableStateOf(false) } var localError by rememberSaveable { mutableStateOf(null) } val controlsEnabled = !isGenerating @@ -375,8 +371,10 @@ private fun CreateAiPlaylistContent( excludeGenres = excludeGenres, preferredLanguage = preferredLanguage, energyLevel = energyLevel, + tempoLevel = tempoLevel, discoveryLevel = discoveryLevel, prioritizeFavorites = prioritizeFavorites, + focusRecent = focusRecent, avoidExplicit = avoidExplicit ) @@ -464,8 +462,10 @@ private fun CreateAiPlaylistContent( selectedActivity = null selectedEra = eraOptionsList.first() energyLevel = 3 + tempoLevel = 3 discoveryLevel = 3 prioritizeFavorites = true + focusRecent = false avoidExplicit = false localError = null }, @@ -589,6 +589,14 @@ private fun CreateAiPlaylistContent( onLevelSelected = { energyLevel = it } ) Spacer(modifier = Modifier.height(10.dp)) + LevelSelector( + label = stringResource(R.string.presentation_batch_e_ai_tempo_label), + selectedLevel = tempoLevel, + enabled = controlsEnabled, + description = stringResource(R.string.presentation_batch_e_ai_tempo_description), + onLevelSelected = { tempoLevel = it } + ) + Spacer(modifier = Modifier.height(10.dp)) LevelSelector( label = stringResource(R.string.presentation_batch_e_ai_discovery_label), selectedLevel = discoveryLevel, @@ -665,6 +673,12 @@ private fun CreateAiPlaylistContent( enabled = controlsEnabled, onCheckedChange = { prioritizeFavorites = it } ) + ToggleRow( + title = stringResource(R.string.presentation_batch_e_ai_focus_recent), + checked = focusRecent, + enabled = controlsEnabled, + onCheckedChange = { focusRecent = it } + ) ToggleRow( title = stringResource(R.string.presentation_batch_e_ai_avoid_explicit), checked = avoidExplicit, @@ -1077,8 +1091,10 @@ private fun buildAiPlaylistPrompt( excludeGenres: String, preferredLanguage: String, energyLevel: Int, + tempoLevel: Int, discoveryLevel: Int, prioritizeFavorites: Boolean, + focusRecent: Boolean, avoidExplicit: Boolean ): String { val anyEraText = res.getString(R.string.presentation_batch_e_ai_era_any) @@ -1119,13 +1135,18 @@ private fun buildAiPlaylistPrompt( } val e = energyLevel.coerceIn(1, 5) + val t = tempoLevel.coerceIn(1, 5) val d = discoveryLevel.coerceIn(1, 5) promptParts += res.getString(R.string.presentation_batch_e_ai_prompt_energy, e) + promptParts += res.getString(R.string.presentation_batch_e_ai_prompt_tempo, t) promptParts += res.getString(R.string.presentation_batch_e_ai_prompt_discovery, d) if (prioritizeFavorites) { promptParts += res.getString(R.string.presentation_batch_e_ai_prompt_prioritize_favorites) } + if (focusRecent) { + promptParts += res.getString(R.string.presentation_batch_e_ai_prompt_focus_recent) + } if (avoidExplicit) { promptParts += res.getString(R.string.presentation_batch_e_ai_prompt_avoid_explicit) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/RoundedParallaxCarousell.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/RoundedParallaxCarousell.kt index 92445df96..3aa790286 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/RoundedParallaxCarousell.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/RoundedParallaxCarousell.kt @@ -576,7 +576,7 @@ private class CarouselItemModifierNode( } // --- limitar además al propio layer (seguro) - val layerBounds = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + val layerBounds = Rect(0f, 0f, size.width, size.height) val maskRect = Rect(left, top, right, bottom).intersect(layerBounds) // --- actualizar info para la máscara (para MaskScope, etc.) 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 566627f04..15499138e 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 @@ -77,7 +77,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 @@ -121,12 +120,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 a4724dbbe..4ab5a7e0f 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 @@ -243,9 +243,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/model/SettingsCategory.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt index ebbcbf6a9..a6e482298 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt @@ -73,6 +73,12 @@ enum class SettingsCategory( subtitleRes = R.string.settings_category_device_capabilities_subtitle, icon = Icons.Rounded.DeveloperBoard // Placeholder, maybe Memory or SettingsInputComponent ), + AI_PREFERENCES( + id = "ai_preferences", + titleRes = R.string.settings_category_ai_preferences_title, + subtitleRes = R.string.settings_category_ai_preferences_subtitle, + iconRes = R.drawable.generate_playlist_ai + ), ABOUT( id = "about", titleRes = R.string.settings_category_about_title, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt index e6f0d02a0..41543ad81 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt @@ -492,6 +492,20 @@ fun AppNavigation( ArtistSettingsScreen(navController = navController) } } + composable( + Screen.AiPreferences.route, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, + ) { + ScreenWrapper(navController = navController, playerViewModel = playerViewModel) { + com.theveloper.pixelplay.presentation.screens.AiPreferencesScreen( + navController = navController, + onNavigationIconClick = { navController.popBackStack() } + ) + } + } composable( Screen.DelimiterConfig.route, enterTransition = { enterTransition() }, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt index 08a7cff5d..89960a13b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt @@ -46,6 +46,7 @@ sealed class Screen(val route: String) { object EasterEgg : Screen("easter_egg") object ArtistSettings : Screen("artist_settings") + object AiPreferences : Screen("ai_preferences") object DelimiterConfig : Screen("delimiter_config") object WordDelimiterConfig : Screen("word_delimiter_config") object Equalizer : Screen("equalizer") diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiPreferencesScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiPreferencesScreen.kt new file mode 100644 index 000000000..1b662642c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiPreferencesScreen.kt @@ -0,0 +1,1395 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.theveloper.pixelplay.presentation.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.ai.local.LocalModelInfo +import com.theveloper.pixelplay.data.ai.local.ModelStatus +import com.theveloper.pixelplay.data.ai.provider.AiProvider +import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel + +@Composable +fun AiPreferencesScreen( + navController: NavController, + onNavigationIconClick: () -> Unit, + settingsViewModel: SettingsViewModel = hiltViewModel() +) { + val uiState by settingsViewModel.uiState.collectAsStateWithLifecycle() + val localModels by settingsViewModel.availableLocalModels.collectAsStateWithLifecycle(initialValue = emptyList()) + val modelStatuses by settingsViewModel.localModelStatuses.collectAsStateWithLifecycle(initialValue = emptyMap()) + val currentAiModel by settingsViewModel.currentAiModel.collectAsStateWithLifecycle(initialValue = "") + val currentApiKey by settingsViewModel.currentAiApiKey.collectAsStateWithLifecycle(initialValue = "") + val currentAiSystemPrompt by settingsViewModel.currentAiSystemPrompt.collectAsStateWithLifecycle(initialValue = "") + + val isOnlineProvider = uiState.aiProvider != "LOCAL" && uiState.aiProvider != "OLLAMA" + val isLocalProvider = uiState.aiProvider == "LOCAL" + val isOllamaProvider = uiState.aiProvider == "OLLAMA" + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_category_ai_preferences_title)) }, + navigationIcon = { + IconButton(onClick = onNavigationIconClick) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text( + text = "Configure AI-powered features like smart playlists and music recommendations.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // ===== PROVIDER SELECTION ===== + item { + Text( + text = "AI Provider", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + + item { + ProviderSelectionCard( + selectedProvider = uiState.aiProvider, + onProviderChange = { settingsViewModel.onAiProviderChange(it) } + ) + } + + // ===== ONLINE PROVIDER SETTINGS ===== + if (isOnlineProvider) { + item { + if (AiProvider.valueOf(uiState.aiProvider).requiresApiKey) { + ApiKeyInputCard( + provider = uiState.aiProvider, + apiKey = currentApiKey, + onApiKeyChange = { settingsViewModel.onAiApiKeyChange(it) } + ) + } + } + + item { + ModelSelectionCard( + provider = uiState.aiProvider, + selectedModel = currentAiModel, + onModelChange = { settingsViewModel.onAiModelChange(it) } + ) + } + + item { + AdvancedSettingsCard( + temperature = (uiState.aiTemperature * 100).toInt(), + maxTokens = uiState.aiMaxTokens, + onTemperatureChange = { settingsViewModel.onAiTemperatureChange(it) }, + onMaxTokensChange = { settingsViewModel.onAiMaxTokensChange(it) } + ) + } + + item { + AdvancedGenerationCard( + topK = uiState.aiTopK, + topP = (uiState.aiTopP * 100).toInt(), + repetitionPenalty = (uiState.aiRepetitionPenalty * 100).toInt(), + frequencyPenalty = (uiState.aiFrequencyPenalty * 100).toInt(), + presencePenalty = (uiState.aiPresencePenalty * 100).toInt(), + onTopKChange = { settingsViewModel.onAiTopKChange(it) }, + onTopPChange = { settingsViewModel.onAiTopPChange(it) }, + onRepetitionPenaltyChange = { settingsViewModel.onAiRepetitionPenaltyChange(it) }, + onFrequencyPenaltyChange = { settingsViewModel.onAiFrequencyPenaltyChange(it) }, + onPresencePenaltyChange = { settingsViewModel.onAiPresencePenaltyChange(it) } + ) + } + + item { + SystemPromptCard( + systemPrompt = currentAiSystemPrompt, + onSystemPromptChange = { settingsViewModel.onAiSystemPromptChange(it) }, + onReset = { settingsViewModel.resetAiSystemPrompt() } + ) + } + } + + // ===== LOCAL/OLLAMA PROVIDER SETTINGS ===== + if (!isOnlineProvider) { + item { + Text( + text = if (isOllamaProvider) "Ollama Server Settings" else "Local Model Settings", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + if (isOllamaProvider) { + item { + ApiKeyInputCard( + provider = "OLLAMA", + apiKey = currentApiKey, + onApiKeyChange = { settingsViewModel.onAiApiKeyChange(it) } + ) + } + item { + ModelSelectionCard( + provider = "OLLAMA", + selectedModel = currentAiModel, + onModelChange = { settingsViewModel.onAiModelChange(it) } + ) + } + item { + OllamaConnectionCard( + ollamaUrl = uiState.localMlOllamaUrl, + onOllamaUrlChange = { settingsViewModel.setLocalMlOllamaUrl(it) } + ) + } + } + } + + // ===== DOWNLOADED MODELS ===== + item { + Text( + text = "Downloaded Models", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + val downloadedModels = localModels.filter { modelStatuses[it.id] is ModelStatus.Ready } + if (downloadedModels.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Info, tint = MaterialTheme.colorScheme.onSurfaceVariant, contentDescription = null) + Spacer(Modifier.width(12.dp)) + Text("No models downloaded yet. Browse and download models below.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + + items(downloadedModels) { model -> + val status = modelStatuses[model.id] ?: ModelStatus.Ready + LocalModelCard( + model = model, status = status, + isSelected = uiState.localMlActiveModelId == model.id, + onDownload = {}, onDelete = { settingsViewModel.deleteLocalModel(model.id) }, + onSelect = { settingsViewModel.selectLocalModel(model.id) }, + onCancel = { settingsViewModel.cancelDownloadModel(model.id) }, enabled = true + ) + } + + // ===== MODEL DOWNLOADS (browse all) ===== + val downloadableModels = localModels.filter { modelStatuses[it.id] !is ModelStatus.Ready } + item { + var expanded by remember { mutableStateOf(false) } + CollapsibleCard( + expanded = expanded, + onToggle = { expanded = !expanded }, + contentPadding = 16.dp, + title = { Text("Browse Models", style = MaterialTheme.typography.titleSmall) } + ) { + downloadableModels.forEach { model -> + val status = modelStatuses[model.id] ?: ModelStatus.NotDownloaded + LocalModelCard( + model = model, status = status, + isSelected = uiState.localMlActiveModelId == model.id, + onDownload = { settingsViewModel.downloadLocalModel(model) }, + onDelete = { settingsViewModel.deleteLocalModel(model.id) }, + onSelect = { settingsViewModel.selectLocalModel(model.id) }, + onCancel = { settingsViewModel.cancelDownloadModel(model.id) }, + enabled = true + ) + } + } + } + + // ===== CONTEXT SETTINGS ===== + item { + Text( + text = "Context Settings", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + item { + ContextSettingsCard( + maxSongsForContext = uiState.maxSongsForContext, + minValue = uiState.maxSongsForContextMin, + maxValue = uiState.maxSongsForContextMax, + includeLikedSongs = uiState.includeLikedSongs, + includeDailyMixHistory = uiState.includeDailyMixHistory, + includeUserHabits = uiState.includeUserHabits, + onMaxSongsForContextChange = { settingsViewModel.onMaxSongsForContextChange(it) }, + onIncludeLikedSongsChange = { settingsViewModel.onIncludeLikedSongsChange(it) }, + onIncludeDailyMixHistoryChange = { settingsViewModel.onIncludeDailyMixHistoryChange(it) }, + onIncludeUserHabitsChange = { settingsViewModel.onIncludeUserHabitsChange(it) } + ) + } + + // ===== TELEMETRY / DATA COLLECTION ===== + item { + Text( + text = "Data Collection & Privacy", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + item { + DataCollectionCard( + telemetryIncludeSkipCount = uiState.telemetryIncludeSkipCount, + telemetryIncludeCompletionRate = uiState.telemetryIncludeCompletionRate, + telemetryIncludeSessionDuration = uiState.telemetryIncludeSessionDuration, + telemetryIncludeTimeOfDay = uiState.telemetryIncludeTimeOfDay, + telemetryIncludeGenreAffinity = uiState.telemetryIncludeGenreAffinity, + telemetryIncludeArtistAffinity = uiState.telemetryIncludeArtistAffinity, + telemetryIncludeReplayCount = uiState.telemetryIncludeReplayCount, + telemetryIncludeQueuePatterns = uiState.telemetryIncludeQueuePatterns, + onSkipCountChange = { settingsViewModel.onTelemetrySkipCountChange(it) }, + onCompletionRateChange = { settingsViewModel.onTelemetryCompletionRateChange(it) }, + onSessionDurationChange = { settingsViewModel.onTelemetrySessionDurationChange(it) }, + onTimeOfDayChange = { settingsViewModel.onTelemetryTimeOfDayChange(it) }, + onGenreAffinityChange = { settingsViewModel.onTelemetryGenreAffinityChange(it) }, + onArtistAffinityChange = { settingsViewModel.onTelemetryArtistAffinityChange(it) }, + onReplayCountChange = { settingsViewModel.onTelemetryReplayCountChange(it) }, + onQueuePatternsChange = { settingsViewModel.onTelemetryQueuePatternsChange(it) } + ) + } + + // ===== CACHE SETTINGS ===== + item { + Text( + text = "Cache Settings", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + item { + CacheSettingsCard( + aiCacheEnabled = uiState.aiCacheEnabled, + aiCacheMaxEntries = uiState.aiCacheMaxEntries, + aiCacheTtlHours = uiState.aiCacheTtlHours, + aiCacheMaxEntriesMin = uiState.aiCacheMaxEntriesMin, + aiCacheMaxEntriesMax = uiState.aiCacheMaxEntriesMax, + aiCacheTtlHoursMin = uiState.aiCacheTtlHoursMin, + aiCacheTtlHoursMax = uiState.aiCacheTtlHoursMax, + onCacheEnabledChange = { settingsViewModel.setAiCacheEnabled(it) }, + onCacheMaxEntriesChange = { settingsViewModel.setAiCacheMaxEntries(it) }, + onCacheTtlHoursChange = { settingsViewModel.setAiCacheTtlHours(it) } + ) + } + + // ===== NOTIFICATION & BEHAVIOR ===== + item { + Text( + text = "Behavior", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + item { + NotificationSettingsCard( + aiIncludeContext = uiState.aiIncludeContext, + aiEnableStreaming = uiState.aiEnableStreaming, + isSafeTokenLimitEnabled = uiState.isSafeTokenLimitEnabled, + onIncludeContextChange = { settingsViewModel.setAiIncludeContext(it) }, + onEnableStreamingChange = { settingsViewModel.setAiEnableStreaming(it) }, + onSafeTokenLimitChange = { settingsViewModel.setSafeTokenLimitEnabled(it) } + ) + } + + // ===== USAGE STATISTICS ===== + item { + Text( + text = "Usage Statistics", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + + item { + UsageStatsCard( + totalInputTokens = uiState.aiUsageTotalInputTokens, + totalOutputTokens = uiState.aiUsageTotalOutputTokens, + totalApiCalls = uiState.aiUsageTotalApiCalls, + estimatedCost = uiState.aiUsageEstimatedCost, + onClearMetrics = { settingsViewModel.clearAiUsageMetrics() } + ) + } + + item { + Spacer(modifier = Modifier.height(32.dp).navigationBarsPadding()) + } + } + } +} + +// ===== PROVIDER SELECTION ===== + +@Composable +fun ProviderSelectionCard( + selectedProvider: String, + onProviderChange: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + val providers = listOf( + "GEMINI" to ("Google Gemini" to "Fast, capable, good for playlists"), + "DEEPSEEK" to ("DeepSeek" to "Fast, affordable, strong reasoning"), + "GROQ" to ("Groq" to "Fast inference, open models"), + "MISTRAL" to ("Mistral" to "High quality, multiple sizes"), + "NVIDIA" to ("NVIDIA NIM" to "GPU-accelerated inference"), + "KIMI" to ("Kimi (Moonshot)" to "Long context support"), + "GLM" to ("Zhipu GLM" to "Chinese + English capable"), + "OPENAI" to ("OpenAI" to "GPT-4o, broadest ecosystem"), + "OPENROUTER" to ("OpenRouter" to "Multi-provider gateway"), + "ANTHROPIC" to ("Anthropic Claude" to "Long context, safe AI"), + "OLLAMA" to ("Ollama Server" to "Connect to your own server"), + "LOCAL" to ("Local (Offline)" to "Run models on-device") + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = providers.find { it.first == selectedProvider }?.second?.first ?: selectedProvider, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + providers.forEach { (key, pair) -> + val (name, desc) = pair + DropdownMenuItem( + text = { + Column { + Text(name, fontWeight = FontWeight.Medium) + Text( + desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onProviderChange(key) + expanded = false + }, + leadingIcon = if (selectedProvider == key) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + } + } + } + } + } +} + +// ===== API KEY ===== + +@Composable +fun ApiKeyInputCard( + provider: String, + apiKey: String, + onApiKeyChange: (String) -> Unit +) { + var hidden by remember { mutableStateOf(true) } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "API Key", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Enter your $provider API key") }, + singleLine = true, + visualTransformation = if (hidden) PasswordVisualTransformation() else VisualTransformation.None, + trailingIcon = { + IconButton(onClick = { hidden = !hidden }) { + Icon( + if (hidden) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = if (hidden) "Show" else "Hide" + ) + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Get your API key from the ${provider.lowercase()} provider dashboard.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +// ===== MODEL SELECTION ===== + +@Composable +fun ModelSelectionCard( + provider: String, + selectedModel: String, + onModelChange: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val aiProvider = remember(provider) { try { AiProvider.valueOf(provider) } catch (_: Exception) { null } } + val providerModels = aiProvider?.models ?: emptyList() + val allModels = remember(providerModels) { if (providerModels.isEmpty()) listOf("default") else providerModels } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Model", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedModel.ifEmpty { "Select model" }, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + allModels.forEach { model -> + val isCompatible = aiProvider == null || aiProvider.models.isEmpty() || model in aiProvider.models + DropdownMenuItem( + text = { + Text( + model, + color = if (isCompatible) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + }, + onClick = { + onModelChange(model) + expanded = false + }, + enabled = isCompatible, + leadingIcon = if (selectedModel == model) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + } + } + } + } + } +} + +// ===== ADVANCED SETTINGS ===== + +@Composable +fun AdvancedSettingsCard( + temperature: Int, + maxTokens: Int, + onTemperatureChange: (Int) -> Unit, + onMaxTokensChange: (Int) -> Unit +) { + var showAdvanced by remember { mutableStateOf(false) } + + CollapsibleCard( + expanded = showAdvanced, + onToggle = { showAdvanced = !showAdvanced }, + contentPadding = 16.dp, + title = { Text("Advanced Settings", style = MaterialTheme.typography.titleSmall) } + ) { + Text(text = "Temperature: ${temperature / 100f}", style = MaterialTheme.typography.bodyMedium) + Slider( + value = temperature.toFloat(), + onValueChange = { onTemperatureChange(it.toInt()) }, + valueRange = 1f..200f, + steps = 19 + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("0.01", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("2.0", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "Max Tokens: $maxTokens", style = MaterialTheme.typography.bodyMedium) + Slider( + value = maxTokens.toFloat(), + onValueChange = { onMaxTokensChange(it.toInt()) }, + valueRange = 128f..16000f, + steps = 19 + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("128", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("16000", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +// ===== ADVANCED GENERATION PARAMETERS ===== + +private data class ParamSliderDef( + val label: String, + val value: Int, + val range: ClosedFloatingPointRange, + val displayValue: String, + val rangeStart: String, + val rangeEnd: String, + val onChange: (Int) -> Unit +) + +@Composable +private fun ParamSlider(param: ParamSliderDef) { + Text(text = "${param.label}: ${param.displayValue}", style = MaterialTheme.typography.bodyMedium) + Slider( + value = param.value.toFloat(), + onValueChange = { param.onChange(it.toInt()) }, + valueRange = param.range, + steps = 19 + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(param.rangeStart, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(param.rangeEnd, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +fun AdvancedGenerationCard( + topK: Int, topP: Int, repetitionPenalty: Int, + frequencyPenalty: Int, presencePenalty: Int, + onTopKChange: (Int) -> Unit, onTopPChange: (Int) -> Unit, + onRepetitionPenaltyChange: (Int) -> Unit, + onFrequencyPenaltyChange: (Int) -> Unit, + onPresencePenaltyChange: (Int) -> Unit +) { + var showAdvanced by remember { mutableStateOf(false) } + val params = remember(topK, topP, repetitionPenalty, frequencyPenalty, presencePenalty) { + listOf( + ParamSliderDef("Top-K", topK, 1f..100f, "$topK", "1", "100", onTopKChange), + ParamSliderDef("Top-P", topP, 1f..100f, "${topP / 100f}", "0.01", "1.0", onTopPChange), + ParamSliderDef("Repetition Penalty", repetitionPenalty, 100f..200f, "${repetitionPenalty / 100f}", "1.0", "2.0", onRepetitionPenaltyChange), + ParamSliderDef("Frequency Penalty", frequencyPenalty, -200f..200f, "${frequencyPenalty / 100f}", "-2.0", "2.0", onFrequencyPenaltyChange), + ParamSliderDef("Presence Penalty", presencePenalty, -200f..200f, "${presencePenalty / 100f}", "-2.0", "2.0", onPresencePenaltyChange) + ) + } + + CollapsibleCard( + expanded = showAdvanced, + onToggle = { showAdvanced = !showAdvanced }, + contentPadding = 16.dp, + title = { Text("Generation Parameters", style = MaterialTheme.typography.titleSmall) } + ) { + params.forEachIndexed { i, param -> + if (i > 0) Spacer(modifier = Modifier.height(12.dp)) + ParamSlider(param) + } + } +} + +// ===== SYSTEM PROMPT ===== + +@Composable +fun SystemPromptCard( + systemPrompt: String, + onSystemPromptChange: (String) -> Unit, + onReset: () -> Unit +) { + var showPrompt by remember { mutableStateOf(false) } + + CollapsibleCard( + expanded = showPrompt, + onToggle = { showPrompt = !showPrompt }, + title = { Text("System Prompt", style = MaterialTheme.typography.titleSmall) } + ) { + OutlinedTextField( + value = systemPrompt, + onValueChange = onSystemPromptChange, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp, max = 200.dp), + maxLines = 8 + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onReset) { + Icon(Icons.Default.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Reset to Default") + } + } +} + +// ===== LOCAL MODEL CARD ===== + +@Composable +fun LocalModelCard( + model: LocalModelInfo, + status: ModelStatus, + isSelected: Boolean, + onDownload: () -> Unit, + onDelete: () -> Unit, + onSelect: () -> Unit, + onCancel: () -> Unit = {}, + enabled: Boolean = true +) { + var showDeleteConfirm by remember { mutableStateOf(false) } + var showDownloadConfirm by remember { mutableStateOf(false) } + val containerAlpha = if (enabled) 1f else 0.4f + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = when { + !enabled -> MaterialTheme.colorScheme.surface + isSelected -> MaterialTheme.colorScheme.primaryContainer + status is ModelStatus.Ready -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surface + } + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = containerAlpha) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = model.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = containerAlpha) + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip( + onClick = {}, + label = { Text(formatSize(model.fileSizeBytes)) }, + leadingIcon = { + Icon( + Icons.Default.Storage, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + AssistChip( + onClick = {}, + label = { Text("${model.ramRequiredMb}MB RAM") }, + leadingIcon = { + Icon( + Icons.Default.Memory, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + } + } + + when (status) { + is ModelStatus.NotDownloaded -> { + FilledTonalButton(onClick = { showDownloadConfirm = true }, enabled = enabled) { + Icon(Icons.Default.Download, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("Download") + } + } + is ModelStatus.Downloading -> { + Column(horizontalAlignment = Alignment.End) { + Text( + text = "${status.progress}%", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + if (status.speedBytesPerSec > 0) { + Text( + text = formatSpeed(status.speedBytesPerSec), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (status.etaSeconds > 0 && status.etaSeconds < Long.MAX_VALUE) { + Text( + text = formatEta(status.etaSeconds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + is ModelStatus.Pending -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Waiting...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + is ModelStatus.Ready -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (isSelected) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Text( + text = "Active", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } else { + OutlinedButton(onClick = onSelect, enabled = enabled) { + Text("Use") + } + } + Spacer(modifier = Modifier.height(4.dp)) + IconButton(onClick = { showDeleteConfirm = true }, enabled = enabled) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = if (enabled) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + ) + } + } + } + is ModelStatus.Error -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) { + Text( + text = status.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(8.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = onDownload, enabled = enabled) { + Text("Retry Download") + } + } + } + } + is ModelStatus.Importing -> { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } + } + + if (status is ModelStatus.Downloading) { + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) { + Box( + modifier = Modifier + .fillMaxWidth(fraction = status.progress / 100f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${formatSize(status.bytesDownloaded)} / ${formatSize(status.totalBytes)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + if (status.progress > 0 && status.progress < 100) { + IconButton( + onClick = onCancel, + modifier = Modifier.size(24.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel download", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + } + + if (showDownloadConfirm) { + AlertDialog( + onDismissRequest = { showDownloadConfirm = false }, + title = { Text("Download Model?") }, + text = { + Text("This will download ${model.displayName} (${formatSize(model.fileSizeBytes)}). " + + "The model will be stored locally and may use significant storage space. " + + "A stable internet connection is recommended.") + }, + confirmButton = { + TextButton(onClick = { + showDownloadConfirm = false + onDownload() + }) { Text("Download") } + }, + dismissButton = { + TextButton(onClick = { showDownloadConfirm = false }) { Text("Cancel") } + } + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete Model?") }, + text = { Text("Are you sure you want to delete ${model.displayName}? You'll need to download it again.") }, + confirmButton = { + TextButton(onClick = { + onDelete() + showDeleteConfirm = false + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + } + ) + } +} + +// ===== OLLAMA CONNECTION ===== + +@Composable +fun OllamaConnectionCard( + ollamaUrl: String, + onOllamaUrlChange: (String) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Ollama Server URL", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = ollamaUrl, + onValueChange = onOllamaUrlChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("https://your-server:11434") }, + singleLine = true + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Enter the URL of your Ollama server (e.g., http://192.168.1.100:11434)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +// ===== CONTEXT SETTINGS ===== + +@Composable +fun ContextSettingsCard( + maxSongsForContext: Int, + minValue: Int, + maxValue: Int, + includeLikedSongs: Boolean, + includeDailyMixHistory: Boolean, + includeUserHabits: Boolean, + onMaxSongsForContextChange: (Int) -> Unit, + onIncludeLikedSongsChange: (Boolean) -> Unit, + onIncludeDailyMixHistoryChange: (Boolean) -> Unit, + onIncludeUserHabitsChange: (Boolean) -> Unit +) { + var showContextSettings by remember { mutableStateOf(false) } + var contextTextInput by remember(maxSongsForContext) { mutableStateOf(maxSongsForContext.toString()) } + + CollapsibleCard( + expanded = showContextSettings, + onToggle = { showContextSettings = !showContextSettings }, + contentPadding = 16.dp, + title = { + Column(modifier = Modifier.weight(1f)) { + Text(text = "Context Size: $maxSongsForContext songs", style = MaterialTheme.typography.titleSmall) + Text(text = "How many songs to include as context ($minValue-$maxValue)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + ) { + Slider( + value = maxSongsForContext.toFloat(), + onValueChange = { + onMaxSongsForContextChange(it.toInt()) + contextTextInput = it.toInt().toString() + }, + valueRange = minValue.toFloat()..maxValue.toFloat(), + steps = 48 + ) + + OutlinedTextField( + value = contextTextInput, + onValueChange = { value -> + contextTextInput = value + value.toIntOrNull()?.let { num -> + onMaxSongsForContextChange(num.coerceIn(minValue, maxValue)) + } + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Exact number of songs") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + + Spacer(modifier = Modifier.height(12.dp)) + + ToggleRow( + title = "Include liked songs", + checked = includeLikedSongs, + onCheckedChange = onIncludeLikedSongsChange + ) + + ToggleRow( + title = "Include listening history", + checked = includeDailyMixHistory, + onCheckedChange = onIncludeDailyMixHistoryChange + ) + + ToggleRow( + title = "Include user habits", + checked = includeUserHabits, + onCheckedChange = onIncludeUserHabitsChange + ) + } +} + +// ===== DATA COLLECTION & PRIVACY ===== + +@Composable +fun DataCollectionCard( + telemetryIncludeSkipCount: Boolean, + telemetryIncludeCompletionRate: Boolean, + telemetryIncludeSessionDuration: Boolean, + telemetryIncludeTimeOfDay: Boolean, + telemetryIncludeGenreAffinity: Boolean, + telemetryIncludeArtistAffinity: Boolean, + telemetryIncludeReplayCount: Boolean, + telemetryIncludeQueuePatterns: Boolean, + onSkipCountChange: (Boolean) -> Unit, + onCompletionRateChange: (Boolean) -> Unit, + onSessionDurationChange: (Boolean) -> Unit, + onTimeOfDayChange: (Boolean) -> Unit, + onGenreAffinityChange: (Boolean) -> Unit, + onArtistAffinityChange: (Boolean) -> Unit, + onReplayCountChange: (Boolean) -> Unit, + onQueuePatternsChange: (Boolean) -> Unit +) { + var showDataCollection by remember { mutableStateOf(false) } + + CollapsibleCard( + expanded = showDataCollection, + onToggle = { showDataCollection = !showDataCollection }, + title = { + Column(modifier = Modifier.weight(1f)) { + Text("Data Collection Preferences", style = MaterialTheme.typography.titleSmall) + Text("Control which listening data is included for AI recommendations. All data stays on-device.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + ) { + ToggleRow("Skip count tracking", telemetryIncludeSkipCount, onSkipCountChange) + ToggleRow("Completion rate", telemetryIncludeCompletionRate, onCompletionRateChange) + ToggleRow("Session duration", telemetryIncludeSessionDuration, onSessionDurationChange) + ToggleRow("Time of day patterns", telemetryIncludeTimeOfDay, onTimeOfDayChange) + ToggleRow("Genre affinity", telemetryIncludeGenreAffinity, onGenreAffinityChange) + ToggleRow("Artist affinity", telemetryIncludeArtistAffinity, onArtistAffinityChange) + ToggleRow("Replay count", telemetryIncludeReplayCount, onReplayCountChange) + ToggleRow("Queue patterns", telemetryIncludeQueuePatterns, onQueuePatternsChange) + Spacer(modifier = Modifier.height(8.dp)) + Text("Disabling all options means AI recommendations will not use your listening history.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +// ===== CACHE SETTINGS ===== + +@Composable +fun CacheSettingsCard( + aiCacheEnabled: Boolean, + aiCacheMaxEntries: Int, + aiCacheTtlHours: Int, + aiCacheMaxEntriesMin: Int, + aiCacheMaxEntriesMax: Int, + aiCacheTtlHoursMin: Int, + aiCacheTtlHoursMax: Int, + onCacheEnabledChange: (Boolean) -> Unit, + onCacheMaxEntriesChange: (Int) -> Unit, + onCacheTtlHoursChange: (Int) -> Unit +) { + var showCache by remember { mutableStateOf(false) } + + CollapsibleCard( + expanded = showCache, + onToggle = { showCache = !showCache }, + contentPadding = 12.dp, + title = { Text("Cache Settings", style = MaterialTheme.typography.titleSmall) } + ) { + ToggleRow("Enable AI response cache", aiCacheEnabled, onCacheEnabledChange) + if (aiCacheEnabled) { + Text(text = "Max cache entries: $aiCacheMaxEntries", style = MaterialTheme.typography.bodyMedium) + Slider( + value = aiCacheMaxEntries.toFloat(), + onValueChange = { onCacheMaxEntriesChange(it.toInt()) }, + valueRange = aiCacheMaxEntriesMin.toFloat()..aiCacheMaxEntriesMax.toFloat(), + steps = 48 + ) + Text(text = "Cache TTL: $aiCacheTtlHours hours", style = MaterialTheme.typography.bodyMedium) + Slider( + value = aiCacheTtlHours.toFloat(), + onValueChange = { onCacheTtlHoursChange(it.toInt()) }, + valueRange = aiCacheTtlHoursMin.toFloat()..aiCacheTtlHoursMax.toFloat(), + steps = 19 + ) + } + } +} + +// ===== NOTIFICATION & BEHAVIOR ===== + +@Composable +fun NotificationSettingsCard( + aiIncludeContext: Boolean, + aiEnableStreaming: Boolean, + isSafeTokenLimitEnabled: Boolean, + onIncludeContextChange: (Boolean) -> Unit, + onEnableStreamingChange: (Boolean) -> Unit, + onSafeTokenLimitChange: (Boolean) -> Unit +) { + var showNotifications by remember { mutableStateOf(false) } + + CollapsibleCard( + expanded = showNotifications, + onToggle = { showNotifications = !showNotifications }, + title = { Text("Notification & Behavior", style = MaterialTheme.typography.titleSmall) } + ) { + ToggleRow("Include context for AI", aiIncludeContext, onIncludeContextChange, subtitle = "Include listening context in AI prompts") + ToggleRow("Enable streaming", aiEnableStreaming, onEnableStreamingChange, subtitle = "Stream AI responses in real-time") + ToggleRow("Safe token limit", isSafeTokenLimitEnabled, onSafeTokenLimitChange, subtitle = "Limit token usage to prevent excessive consumption") + } +} + +// ===== USAGE STATISTICS ===== + +@Composable +fun UsageStatsCard( + totalInputTokens: Long, + totalOutputTokens: Long, + totalApiCalls: Long, + estimatedCost: String, + onClearMetrics: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("AI Usage Statistics", style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(12.dp)) + + StatRow("Total API Calls", totalApiCalls.toString()) + StatRow("Total Input Tokens", totalInputTokens.toString()) + StatRow("Total Output Tokens", totalOutputTokens.toString()) + StatRow("Estimated Cost", "$${estimatedCost}") + + if (totalApiCalls > 0) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onClearMetrics, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Clear Metrics") + } + } + } + } +} + +// ===== GENERIC COMPONENTS ===== + +@Composable +fun StatRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } +} + +@Composable +fun ToggleRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + subtitle: String? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyMedium) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +@Composable +fun SwitchPreference( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true +) { + Card( + modifier = Modifier + .fillMaxWidth() + .then(if (enabled) Modifier.clickable { onCheckedChange(!checked) } else Modifier), + colors = CardDefaults.cardColors( + containerColor = if (enabled) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } + } +} + +// ===== GENERIC COLLAPSIBLE CARD ===== + +@Composable +private fun CollapsibleCard( + expanded: Boolean, + onToggle: () -> Unit, + title: @Composable RowScope.() -> Unit, + contentPadding: Dp = 8.dp, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + title() + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null + ) + } + AnimatedVisibility(visible = expanded) { + Column(modifier = Modifier.padding(top = contentPadding)) { + content() + } + } + } + } +} + +private fun formatSize(bytes: Long): String { + return when { + bytes >= 1_000_000_000 -> "%.1fGB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1fMB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.1fKB".format(bytes / 1_000.0) + else -> "$bytes B" + } +} + +private fun formatSpeed(bytesPerSec: Long): String { + return when { + bytesPerSec >= 1_000_000 -> "%.1f MB/s".format(bytesPerSec / 1_000_000.0) + bytesPerSec >= 1_000 -> "%.1f KB/s".format(bytesPerSec / 1_000.0) + else -> "$bytesPerSec B/s" + } +} + +private fun formatEta(seconds: Long): String { + return when { + seconds <= 0 -> "Almost done" + seconds < 60 -> "${seconds}s remaining" + seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s remaining" + else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m remaining" + } +} 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 3fb80a1b8..56d4e5c75 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 @@ -480,9 +480,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 fea8bdf4f..8e67417bf 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 5e03f655b..a839f2205 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 @@ -123,8 +123,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) } @@ -234,14 +232,7 @@ fun DailyMixScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = removeFromListTrigger, - isGeneratingMetadata = isGeneratingAiMetadata, - aiMetadataSuccess = aiMetadataSuccess, - aiError = aiError, - onRetryMetadata = { playerViewModel.retryLastMetadataGeneration() } ) 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 94a18251a..6076cfb7f 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 @@ -545,9 +545,6 @@ fun GenreDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt index d3991a9b3..5c049f320 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt @@ -214,7 +214,7 @@ fun LibraryAlbumsTab( when { refreshState is LoadState.Error && albums.itemCount == 0 -> { - val error = (refreshState as LoadState.Error).error + val error = refreshState.error Box( modifier = Modifier .fillMaxSize() @@ -524,7 +524,7 @@ fun LibraryArtistsTab( when { refreshState is LoadState.Error && artists.itemCount == 0 -> { - val error = (refreshState as LoadState.Error).error + val error = refreshState.error Box( modifier = Modifier .fillMaxSize() 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 8a4ff0a67..e1b5c9865 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 @@ -4,7 +4,7 @@ package com.theveloper.pixelplay.presentation.screens import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.navigation.navigateSafelyReplacing - +import com.theveloper.pixelplay.presentation.components.SmartImage import android.os.Trace import android.text.format.Formatter import androidx.activity.compose.BackHandler @@ -139,7 +139,6 @@ import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.StorageFilter import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight -import com.theveloper.pixelplay.presentation.components.SmartImage import com.theveloper.pixelplay.presentation.components.resolveMainScreenBottomGradientHeight import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight import androidx.compose.foundation.layout.WindowInsets @@ -399,7 +398,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 @@ -421,7 +419,6 @@ private fun PlayerUiState.toLibraryScreenProjection(): LibraryScreenPlayerProjec isSdCardAvailable = isSdCardAvailable, musicFolders = musicFolders, isLoadingLibraryCategories = isLoadingLibraryCategories, - isGeneratingAiMetadata = isGeneratingAiMetadata, isSyncingLibrary = isSyncingLibrary, isLoadingInitialSongs = isLoadingInitialSongs, hideLocalMedia = hideLocalMedia @@ -1495,241 +1492,226 @@ fun LibraryScreen( ) } - // Box wrapper to allow floating SelectionCountPill overlay - Box(modifier = Modifier.fillMaxSize()) { - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(top = 8.dp), - pageSpacing = 0.dp, - beyondViewportPageCount = 1, // Pre-load adjacent tabs to reduce lag when switching - key = { it } - ) { page -> - val tabIndex = resolveTabIndex( - page = page, - tabCount = tabTitles.size, - compactMode = isCompactNavigation - ) - when (tabTitles.getOrNull(tabIndex)?.toLibraryTabIdOrNull()) { - LibraryTabId.SONGS -> { - LibrarySongsTab( - songs = allSongsLazyPagingItems, - isLoading = isLibraryLoading, - playerViewModel = playerViewModel, - bottomBarHeight = bottomBarHeightDp, - onMoreOptionsClick = stableOnMoreOptionsClick, - isRefreshing = isRefreshing, - onRefresh = { - onRefresh() - allSongsLazyPagingItems.refresh() - }, - isSelectionMode = isSelectionMode, - selectedSongIds = selectedSongIds, - onSongLongPress = onSongLongPress, - onSongSelectionToggle = onSongSelectionToggle, - getSelectionIndex = playerViewModel.multiSelectionStateHolder::getSelectionIndex, - onLocateCurrentSongVisibilityChanged = { songsShowLocateButton = it }, - onRegisterLocateCurrentSongAction = { songsLocateAction = it }, - sortOption = playerUiState.currentSongSortOption, - storageFilter = playerUiState.currentStorageFilter, - hasCurrentSong = hasCurrentSong - ) - } - LibraryTabId.ALBUMS -> { - val isLoading = playerUiState.isLoadingLibraryCategories - - val stableOnAlbumClick: (Long) -> Unit = remember(navController) { - { albumId: Long -> - navController.navigateSafelyReplacing( - route = Screen.AlbumDetail.createRoute(albumId), - patternToPop = Screen.AlbumDetail.route - ) + // FIX: Added the if condition here to match the else if below + if (!isLibraryContentEmpty || !(playerUiState.isSyncingLibrary || playerUiState.isLoadingInitialSongs || playerUiState.isLoadingLibraryCategories)) { + // Box wrapper to allow floating SelectionCountPill overlay + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp), + pageSpacing = 0.dp, + beyondViewportPageCount = 1, + key = { it } + ) { page -> + val tabIndex = resolveTabIndex( + page = page, + tabCount = tabTitles.size, + compactMode = isCompactNavigation + ) + when (tabTitles.getOrNull(tabIndex)?.toLibraryTabIdOrNull()) { + LibraryTabId.SONGS -> { + LibrarySongsTab( + songs = allSongsLazyPagingItems, + isLoading = isLibraryLoading, + playerViewModel = playerViewModel, + bottomBarHeight = bottomBarHeightDp, + onMoreOptionsClick = stableOnMoreOptionsClick, + isRefreshing = isRefreshing, + onRefresh = { + onRefresh() + allSongsLazyPagingItems.refresh() + }, + isSelectionMode = isSelectionMode, + selectedSongIds = selectedSongIds, + onSongLongPress = onSongLongPress, + onSongSelectionToggle = onSongSelectionToggle, + getSelectionIndex = playerViewModel.multiSelectionStateHolder::getSelectionIndex, + onLocateCurrentSongVisibilityChanged = { songsShowLocateButton = it }, + onRegisterLocateCurrentSongAction = { songsLocateAction = it }, + sortOption = playerUiState.currentSongSortOption, + storageFilter = playerUiState.currentStorageFilter, + hasCurrentSong = hasCurrentSong + ) + } + LibraryTabId.ALBUMS -> { + val isLoading = playerUiState.isLoadingLibraryCategories + + val stableOnAlbumClick: (Long) -> Unit = remember(navController) { + { albumId: Long -> + navController.navigateSafelyReplacing( + route = Screen.AlbumDetail.createRoute(albumId), + patternToPop = Screen.AlbumDetail.route + ) + } } + LibraryAlbumsTab( + albums = albumsLazyPagingItems, + isLoading = isLoading, + playerViewModel = playerViewModel, + bottomBarHeight = bottomBarHeightDp, + isListView = playerUiState.isAlbumsListView, + currentAlbumSortOption = playerUiState.currentAlbumSortOption, + onAlbumClick = stableOnAlbumClick, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + isSelectionMode = isAlbumSelectionMode, + selectedAlbumIds = selectedAlbumIds, + onAlbumLongPress = onAlbumLongPress, + onAlbumSelectionToggle = onAlbumSelectionToggle, + getSelectionIndex = getAlbumSelectionIndex, + storageFilter = playerUiState.currentStorageFilter + ) } - LibraryAlbumsTab( - albums = albumsLazyPagingItems, - isLoading = isLoading, - playerViewModel = playerViewModel, - bottomBarHeight = bottomBarHeightDp, - isListView = playerUiState.isAlbumsListView, - currentAlbumSortOption = playerUiState.currentAlbumSortOption, - onAlbumClick = stableOnAlbumClick, - isRefreshing = isRefreshing, - onRefresh = onRefresh, - isSelectionMode = isAlbumSelectionMode, - selectedAlbumIds = selectedAlbumIds, - onAlbumLongPress = onAlbumLongPress, - onAlbumSelectionToggle = onAlbumSelectionToggle, - getSelectionIndex = getAlbumSelectionIndex, - storageFilter = playerUiState.currentStorageFilter - ) - } - LibraryTabId.ARTISTS -> { - val isLoading = playerUiState.isLoadingLibraryCategories - - LibraryArtistsTab( - artists = artistsLazyPagingItems, - isLoading = isLoading, - playerViewModel = playerViewModel, - bottomBarHeight = bottomBarHeightDp, - currentArtistSortOption = playerUiState.currentArtistSortOption, - onArtistClick = { artistId -> - navController.navigateSafelyReplacing( - route = Screen.ArtistDetail.createRoute(artistId), - patternToPop = Screen.ArtistDetail.route - ) - }, - isRefreshing = isRefreshing, - onRefresh = onRefresh, - storageFilter = playerUiState.currentStorageFilter - ) - } + LibraryTabId.ARTISTS -> { + val isLoading = playerUiState.isLoadingLibraryCategories + + LibraryArtistsTab( + artists = artistsLazyPagingItems, + isLoading = isLoading, + playerViewModel = playerViewModel, + bottomBarHeight = bottomBarHeightDp, + currentArtistSortOption = playerUiState.currentArtistSortOption, + onArtistClick = { artistId -> + navController.navigateSafelyReplacing( + route = Screen.ArtistDetail.createRoute(artistId), + patternToPop = Screen.ArtistDetail.route + ) + }, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + storageFilter = playerUiState.currentStorageFilter + ) + } - LibraryTabId.PLAYLISTS -> { - LibraryPlaylistsTab( - playlistUiState = playlistUiState, - filteredPlaylists = visiblePlaylists, - navController = navController, - playerViewModel = playerViewModel, - bottomBarHeight = bottomBarHeightDp, - isRefreshing = isRefreshing, - onRefresh = onRefresh, + LibraryTabId.PLAYLISTS -> { + LibraryPlaylistsTab( + playlistUiState = playlistUiState, + filteredPlaylists = visiblePlaylists, + navController = navController, + playerViewModel = playerViewModel, + bottomBarHeight = bottomBarHeightDp, + isRefreshing = isRefreshing, + onRefresh = onRefresh, // Playlist multi-selection - isSelectionMode = isPlaylistSelectionMode, - selectedPlaylistIds = selectedPlaylistIds, - onPlaylistLongPress = onPlaylistLongPress, - onPlaylistSelectionToggle = onPlaylistSelectionToggle, - onPlaylistOptionsClick = { showPlaylistMultiSelectionSheet = true } - ) - } + isSelectionMode = isPlaylistSelectionMode, + selectedPlaylistIds = selectedPlaylistIds, + onPlaylistLongPress = onPlaylistLongPress, + onPlaylistSelectionToggle = onPlaylistSelectionToggle, + onPlaylistOptionsClick = { showPlaylistMultiSelectionSheet = true } + ) + } - LibraryTabId.LIKED -> { - LibraryFavoritesTab( - favoriteSongs = favoritePagingItems, - playerViewModel = playerViewModel, - bottomBarHeight = bottomBarHeightDp, - onMoreOptionsClick = stableOnMoreOptionsClick, - isRefreshing = isRefreshing, - onRefresh = { - onRefresh() - favoritePagingItems.refresh() - }, - isSelectionMode = isSelectionMode, - selectedSongIds = selectedSongIds, - onSongLongPress = onSongLongPress, - onSongSelectionToggle = onSongSelectionToggle, - getSelectionIndex = playerViewModel.multiSelectionStateHolder::getSelectionIndex, - sortOption = playerUiState.currentFavoriteSortOption, - onLocateCurrentSongVisibilityChanged = { likedShowLocateButton = it }, - onRegisterLocateCurrentSongAction = { likedLocateAction = it }, - storageFilter = playerUiState.currentStorageFilter, - hasCurrentSong = hasCurrentSong - ) - } + LibraryTabId.LIKED -> { + LibraryFavoritesTab( + favoriteSongs = favoritePagingItems, + playerViewModel = playerViewModel, + bottomBarHeight = bottomBarHeightDp, + onMoreOptionsClick = stableOnMoreOptionsClick, + isRefreshing = isRefreshing, + onRefresh = { + onRefresh() + favoritePagingItems.refresh() + }, + isSelectionMode = isSelectionMode, + selectedSongIds = selectedSongIds, + onSongLongPress = onSongLongPress, + onSongSelectionToggle = onSongSelectionToggle, + getSelectionIndex = playerViewModel.multiSelectionStateHolder::getSelectionIndex, + sortOption = playerUiState.currentFavoriteSortOption, + onLocateCurrentSongVisibilityChanged = { likedShowLocateButton = it }, + onRegisterLocateCurrentSongAction = { likedLocateAction = it }, + storageFilter = playerUiState.currentStorageFilter, + hasCurrentSong = hasCurrentSong + ) + } - LibraryTabId.FOLDERS -> { - val folders = playerUiState.musicFolders - val currentFolder = playerUiState.currentFolder - val isLoading = playerUiState.isLoadingLibraryCategories - val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() - val defaultFolderName = stringResource(R.string.presentation_batch_d_folder_name_fallback) - - LibraryFoldersTab( - folders = folders, - currentFolder = currentFolder, - isLoading = isLoading, - bottomBarHeight = bottomBarHeightDp, - stablePlayerState = stablePlayerState, - onNavigateBack = { playerViewModel.navigateBackFolder() }, - onFolderClick = { folderPath -> playerViewModel.navigateToFolder(folderPath) }, - onFolderAsPlaylistClick = { folder -> - val encodedPath = Uri.encode(folder.path) - navController.navigateSafelyReplacing( - route = Screen.PlaylistDetail.createRoute( - "${PlaylistViewModel.FOLDER_PLAYLIST_PREFIX}$encodedPath" - ), - patternToPop = Screen.PlaylistDetail.route - ) - }, - onPlaySong = { song, queue -> - playerViewModel.showAndPlaySong( - song, - queue, - currentFolder?.name ?: defaultFolderName - ) - }, - onMoreOptionsClick = stableOnMoreOptionsClick, - isPlaylistView = playerUiState.isFoldersPlaylistView, - currentSortOption = playerUiState.currentFolderSortOption, - isRefreshing = isRefreshing, - onRefresh = onRefresh, - isSelectionMode = isSelectionMode, - selectedSongIds = selectedSongIds, - onSongLongPress = onSongLongPress, - onSongSelectionToggle = onSongSelectionToggle, - getSelectionIndex = playerViewModel.multiSelectionStateHolder::getSelectionIndex, - onLocateCurrentSongVisibilityChanged = { foldersShowLocateButton = it }, - onRegisterLocateCurrentSongAction = { foldersLocateAction = it }, - pendingLocatePath = pendingFoldersLocatePath, - onClearPendingLocate = { pendingFoldersLocatePath = null }, - onRequestCrossFolderLocate = { folderPath -> - pendingFoldersLocatePath = folderPath - playerViewModel.navigateToFolder(folderPath) - } - ) - } + LibraryTabId.FOLDERS -> { + val folders = playerUiState.musicFolders + val currentFolder = playerUiState.currentFolder + val isLoading = playerUiState.isLoadingLibraryCategories + val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() + val defaultFolderName = stringResource(R.string.presentation_batch_d_folder_name_fallback) + + LibraryFoldersTab( + folders = folders, + currentFolder = currentFolder, + isLoading = isLoading, + bottomBarHeight = bottomBarHeightDp, + stablePlayerState = stablePlayerState, + onNavigateBack = { playerViewModel.navigateBackFolder() }, + onFolderClick = { folderPath -> playerViewModel.navigateToFolder(folderPath) }, + onFolderAsPlaylistClick = { folder -> + val encodedPath = Uri.encode(folder.path) + navController.navigateSafelyReplacing( + route = Screen.PlaylistDetail.createRoute( + "${PlaylistViewModel.FOLDER_PLAYLIST_PREFIX}$encodedPath" + ), + patternToPop = Screen.PlaylistDetail.route + ) + }, + onPlaySong = { song, queue -> + playerViewModel.showAndPlaySong( + song, + queue, + currentFolder?.name ?: defaultFolderName + ) + }, + onMoreOptionsClick = stableOnMoreOptionsClick, + isPlaylistView = playerUiState.isFoldersPlaylistView, + currentSortOption = playerUiState.currentFolderSortOption, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + isSelectionMode = isSelectionMode, + selectedSongIds = selectedSongIds, + onSongLongPress = onSongLongPress, + onSongSelectionToggle = onSongSelectionToggle, + getSelectionIndex = playerViewModel.multiSelectionStateHolder::getSelectionIndex, + onLocateCurrentSongVisibilityChanged = { foldersShowLocateButton = it }, + onRegisterLocateCurrentSongAction = { foldersLocateAction = it }, + pendingLocatePath = pendingFoldersLocatePath, + onClearPendingLocate = { pendingFoldersLocatePath = null }, + onRequestCrossFolderLocate = { folderPath -> + pendingFoldersLocatePath = folderPath + playerViewModel.navigateToFolder(folderPath) + } + ) + } - null -> Unit + null -> Unit + } } - } - // Floating selection count pill overlay - val selectionCount = when { - currentTabId == LibraryTabId.PLAYLISTS && isPlaylistSelectionMode -> selectedPlaylists.size - currentTabId == LibraryTabId.ALBUMS && isAlbumSelectionMode -> selectedAlbums.size - else -> selectedSongs.size - } - SelectionCountPill( - selectedCount = selectionCount, - modifier = Modifier - .align(Alignment.TopCenter) - .zIndex(1f) - ) - } - } - } - 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.presentation_batch_d_generating_ai_metadata), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + // Floating selection count pill overlay + val selectionCount = when { + currentTabId == LibraryTabId.PLAYLISTS && isPlaylistSelectionMode -> selectedPlaylists.size + currentTabId == LibraryTabId.ALBUMS && isAlbumSelectionMode -> selectedAlbums.size + else -> selectedSongs.size + } + SelectionCountPill( + selectedCount = selectionCount, + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(1f) ) } - } - } - } else if ( - isLibraryContentEmpty && - ( - playerUiState.isSyncingLibrary || - playerUiState.isLoadingInitialSongs || - playerUiState.isLoadingLibraryCategories - ) - ) { + } else if ( + isLibraryContentEmpty && + ( + playerUiState.isSyncingLibrary || + playerUiState.isLoadingInitialSongs || + playerUiState.isLoadingLibraryCategories + ) + ) { // The full-screen overlay is reserved for first-launch / empty library // states. Once the user has content, in-place indicators (pull-to-refresh // spinner + LibraryInlineSyncIndicator) handle sync feedback so the // list stays visible. - LibrarySyncOverlay(syncManager = syncManager) + LibrarySyncOverlay(syncManager = syncManager) + } + } } } //Grad box @@ -1927,9 +1909,6 @@ fun LibraryScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = {}, songInfoViewModel = songInfoBottomSheetViewModel ) @@ -3759,4 +3738,4 @@ fun AlbumListItem( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt index 24e9b98f1..392410d4c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt @@ -225,7 +225,7 @@ fun LibrarySongsTab( when { refreshState is LoadState.Error && songs.itemCount == 0 -> { - val error = (refreshState as LoadState.Error).error + val error = refreshState.error Box( modifier = Modifier .fillMaxSize() 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 778440c30..bea21846a 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 @@ -920,9 +920,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 11cea315f..8d88edf6b 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 f1fdaf33d..658cd3196 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 @@ -513,9 +513,6 @@ fun SearchScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, ) if (showPlaylistBottomSheet) { val playlistUiState by playlistViewModel.uiState.collectAsStateWithLifecycle() 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 e7bfaf99b..99b79d3f0 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 @@ -918,6 +918,9 @@ fun SettingsCategoryScreen( com.theveloper.pixelplay.data.ai.provider.AiProvider.GLM -> stringResource(R.string.setcat_ai_source_glm) com.theveloper.pixelplay.data.ai.provider.AiProvider.OPENAI -> stringResource(R.string.setcat_ai_source_openai) com.theveloper.pixelplay.data.ai.provider.AiProvider.OPENROUTER -> "OpenRouter (openrouter.ai)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.ANTHROPIC -> "Anthropic Claude (anthropic.com)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.OLLAMA -> "Ollama (ollama.ai)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.LOCAL -> "Local Model (Device)" } AiApiKeyItem( @@ -929,7 +932,7 @@ fun SettingsCategoryScreen( } // Model Selection Section - if (currentAiApiKey.isNotBlank()) { + if (currentAiApiKey.isNotBlank() || !com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider).requiresApiKey) { SettingsSubsection(title = stringResource(R.string.setcat_model_selection)) { if (uiState.isLoadingModels) { Surface( @@ -1225,6 +1228,9 @@ fun SettingsCategoryScreen( SettingsCategory.DEVICE_CAPABILITIES -> { // Device Capabilities has its own screen } + SettingsCategory.AI_PREFERENCES -> { + // AI Preferences has its own screen + } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt index 2aaec984e..7c5a7a377 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt @@ -236,10 +236,16 @@ fun SettingsScreen( category = category, customColors = colors, onClick = { - if (category == SettingsCategory.EQUALIZER) { - navController.navigateSafely(Screen.Equalizer.route) - } else { - navController.navigateSafely(Screen.SettingsCategory.createRoute(category.id)) + when { + category == SettingsCategory.EQUALIZER -> { + navController.navigateSafely(Screen.Equalizer.route) + } + category == SettingsCategory.AI_PREFERENCES -> { + navController.navigateSafely(Screen.AiPreferences.route) + } + else -> { + navController.navigateSafely(Screen.SettingsCategory.createRoute(category.id)) + } } }, shape = shapeFor(itemIndex) @@ -483,6 +489,7 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair SettingsCategory.PLAYBACK -> Color(0xFF633B48) to Color(0xFFFFD8EC) SettingsCategory.BEHAVIOR -> Color(0xFF3E4C63) to Color(0xFFD7E3FF) SettingsCategory.AI_INTEGRATION -> Color(0xFF004F58) to Color(0xFF88FAFF) + SettingsCategory.AI_PREFERENCES -> Color(0xFF4C4274) to Color(0xFFE4DFFF) SettingsCategory.BACKUP_RESTORE -> Color(0xFF3B4869) to Color(0xFFD9E2FF) SettingsCategory.DEVELOPER -> Color(0xFF324F34) to Color(0xFFCBEFD0) SettingsCategory.EQUALIZER -> Color(0xFF6E4E13) to Color(0xFFFFDEAC) @@ -496,6 +503,7 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair SettingsCategory.PLAYBACK -> Color(0xFFFFD8EC) to Color(0xFF631B4B) SettingsCategory.BEHAVIOR -> Color(0xFFD7E3FF) to Color(0xFF253347) SettingsCategory.AI_INTEGRATION -> Color(0xFFCCE8EA) to Color(0xFF004F58) + SettingsCategory.AI_PREFERENCES -> Color(0xFFE4DFFF) to Color(0xFF4C4274) SettingsCategory.BACKUP_RESTORE -> Color(0xFFD9E2FF) to Color(0xFF27304E) SettingsCategory.DEVELOPER -> Color(0xFFCBEFD0) to Color(0xFF042106) SettingsCategory.EQUALIZER -> Color(0xFFFFDEAC) to Color(0xFF281900) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt index 354d92987..1f2ee30e6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt @@ -114,12 +114,12 @@ class AccountsViewModel @Inject constructor( ) { it.toList() }, loggingOutServices ) { states, activeLogouts -> - val (telegramConnected, telegramChannelCount) = states[0] as Pair - val (gDriveConnected, gDriveFolderCount) = states[1] as Pair - val (neteaseConnected, neteasePlaylistCount) = states[2] as Pair - val (qqConnected, qqPlaylistCount) = states[3] as Pair - val (navidromeConnected, navidromePlaylistCount) = states[4] as Pair - val (jellyfinConnected, jellyfinPlaylistCount) = states[5] as Pair + val (telegramConnected, telegramChannelCount) = states[0] + val (gDriveConnected, gDriveFolderCount) = states[1] + val (neteaseConnected, neteasePlaylistCount) = states[2] + val (qqConnected, qqPlaylistCount) = states[3] + val (navidromeConnected, navidromePlaylistCount) = states[4] + val (jellyfinConnected, jellyfinPlaylistCount) = states[5] val connectedAccounts = buildList { if (telegramConnected) { 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 1952813f3..56289ad26 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,38 +284,6 @@ class AiStateHolder @Inject constructor( } } - /** - * Fetches AI-generated metadata (tags, genre, lyrics) for a specific song. - * Updates internal success and error states for UI feedback. - */ - suspend fun generateAiMetadata(song: Song, fields: List): Result { - _lastMetadataSong = song - _lastMetadataFields = fields - - _isGeneratingMetadata.value = true - _aiMetadataSuccess.value = false - _aiError.value = null - - return try { - val result = aiMetadataGenerator.generate(song, fields) - if (result.isSuccess) { - _aiMetadataSuccess.value = true - notificationManager.showCompletion("Metadata Enhanced", "Applied tags and genre refinements.") - } else { - result.exceptionOrNull()?.let { - _aiError.value = resolveAiErrorMessage(it) - notificationManager.showCompletion("Metadata Error", "Check your AI configuration.") - } - } - result - } catch (e: Exception) { - _aiError.value = resolveAiErrorMessage(e) - Result.failure(e) - } finally { - _isGeneratingMetadata.value = false - } - } - suspend fun translateLyrics(lyricsText: String): Result { return try { val targetLanguage = context.resources.configuration.locales[0].displayLanguage @@ -363,7 +307,7 @@ Lyrics to translate: $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/CastTransferStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt index 124740938..59424a0ba 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt @@ -1033,7 +1033,7 @@ class CastTransferStateHolder @Inject constructor( instanceFollowRedirects = false requestMethod = method } - val code = connection?.responseCode ?: -1 + val code = connection.responseCode code in 200..299 }.getOrDefault(false).also { connection?.disconnect() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt index f86f46be3..af7880e13 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt @@ -331,7 +331,7 @@ class ConnectivityStateHolder @Inject constructor( ) { val name = device.productName?.toString()?.trim().orEmpty() if (name.isNotEmpty() && !isOwnBluetoothDeviceName(name, localDeviceNames)) { - val address = device.address?.trim().orEmpty().takeIf { it.isNotEmpty() } + val address = device.address.trim().orEmpty().takeIf { it.isNotEmpty() } val key = bluetoothDeviceKey(address, name) connectedDevices[key] = BluetoothAudioDeviceState( name = name, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt index 3c5c821e6..8c8818197 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt @@ -158,7 +158,7 @@ class GenreDetailViewModel @Inject constructor( val sections = buildDisplaySections(songs, SortOption.ARTIST) val flattened = flattenSections(sections, artistMap) - val sorted = songs.sortedBy { it.artist ?: "Unknown Artist" } + val sorted = songs.sortedBy { it.artist } ProcessingResult(genre, songs, sorted, sections, flattened) } @@ -195,8 +195,8 @@ class GenreDetailViewModel @Inject constructor( val sections = buildDisplaySections(currentState.songs, newSort) val flattened = flattenSections(sections, artistMap) val sorted = when (newSort) { - SortOption.ARTIST -> currentState.songs.sortedBy { it.artist ?: "Unknown Artist" } - SortOption.ALBUM -> currentState.songs.sortedBy { it.album ?: "Unknown Album" } + SortOption.ARTIST -> currentState.songs.sortedBy { it.artist } + SortOption.ALBUM -> currentState.songs.sortedBy { it.album } SortOption.TITLE -> currentState.songs.sortedBy { it.title } } Triple(sections, flattened, sorted) @@ -278,10 +278,10 @@ class GenreDetailViewModel @Inject constructor( private fun buildDisplaySections(songs: List, sort: SortOption): List { return when (sort) { SortOption.ARTIST -> { - val sorted = songs.sortedBy { it.artist ?: "Unknown Artist" } - val grouped = sorted.groupBy { it.artist ?: "Unknown Artist" } + val sorted = songs.sortedBy { it.artist } + val grouped = sorted.groupBy { it.artist } grouped.map { (artist, artistSongs) -> - val albums = artistSongs.groupBy { it.album ?: "Unknown Album" }.map { (albumName, albumSongs) -> + val albums = artistSongs.groupBy { it.album }.map { (albumName, albumSongs) -> val sortedAlbumSongs = albumSongs.sortedWith( compareBy { it.discNumber ?: 1 } .thenBy { if (it.trackNumber > 0) it.trackNumber else Int.MAX_VALUE } @@ -293,8 +293,8 @@ class GenreDetailViewModel @Inject constructor( } } SortOption.ALBUM -> { - val sorted = songs.sortedBy { it.album ?: "Unknown Album" } - val grouped = sorted.groupBy { it.album ?: "Unknown Album" } + val sorted = songs.sortedBy { it.album } + val grouped = sorted.groupBy { it.album } grouped.map { (album, albumSongs) -> val sortedAlbumSongs = albumSongs.sortedWith( compareBy { it.discNumber ?: 1 } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ListeningStatsTracker.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ListeningStatsTracker.kt index 3bb9dc2dd..f25faa8ac 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ListeningStatsTracker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ListeningStatsTracker.kt @@ -226,7 +226,15 @@ class ListeningStatsTracker @Inject constructor( val nowEpoch = System.currentTimeMillis() accumulateRealtimeListening(session, nowRealtime) val listened = session.accumulatedListeningMs.coerceAtLeast(0L) - if (listened >= MIN_SESSION_LISTEN_MS) { + val totalDuration = session.totalDurationMs + + // Define skip and completion thresholds: + // Skip: listened duration < 15 seconds AND (totalDuration <= 0L OR listened < totalDuration * 0.25) + // Completion: totalDuration > 0L AND (listened >= totalDuration * 0.9 OR (totalDuration - listened) <= 10000L) + val isSkip = listened < 15000L && (totalDuration <= 0L || listened < totalDuration * 0.25) + val isCompletion = totalDuration > 0L && (listened >= totalDuration * 0.9 || (totalDuration - listened) <= 10000L) + + if (listened >= MIN_SESSION_LISTEN_MS || isSkip) { val rawEndTimestamp = when { session.isPlaying -> nowEpoch session.lastUpdateEpochMs > 0L -> session.lastUpdateEpochMs @@ -243,10 +251,18 @@ class ListeningStatsTracker @Inject constructor( _playbackHistory.update { current -> (listOf(historyEntry) + current).take(MAX_INTERNAL_PLAYBACK_HISTORY_ITEMS) } + + val playInc = if (isSkip) 0 else 1 + val skipInc = if (isSkip) 1 else 0 + val completedInc = if (isCompletion) 1 else 0 + persistPlayback( songId = songId, listened = listened, timestamp = timestamp, + playInc = playInc, + skipInc = skipInc, + completedInc = completedInc, forceSynchronous = forceSynchronousPersistence ) } @@ -267,33 +283,55 @@ class ListeningStatsTracker @Inject constructor( scope = null } - @Suppress("UNUSED_PARAMETER") private fun persistPlayback( songId: String, listened: Long, timestamp: Long, + playInc: Int, + skipInc: Int, + completedInc: Int, forceSynchronous: Boolean ) { persistenceScope.launch { runCatching { - persistPlaybackInternal(songId = songId, listened = listened, timestamp = timestamp) + persistPlaybackInternal( + songId = songId, + listened = listened, + timestamp = timestamp, + playInc = playInc, + skipInc = skipInc, + completedInc = completedInc + ) }.onFailure { throwable -> Timber.e(throwable, "Failed to persist listening session for song=%s", songId) } } } - private suspend fun persistPlaybackInternal(songId: String, listened: Long, timestamp: Long) { - dailyMixManager.recordPlay( + private suspend fun persistPlaybackInternal( + songId: String, + listened: Long, + timestamp: Long, + playInc: Int, + skipInc: Int, + completedInc: Int + ) { + dailyMixManager.recordEngagement( songId = songId, + playInc = playInc, songDurationMs = listened, - timestamp = timestamp - ) - playbackStatsRepository.recordPlayback( - songId = songId, - durationMs = listened, - timestamp = timestamp + timestamp = timestamp, + skipInc = skipInc, + completedInc = completedInc ) + // For playback stats repository, we only record if it wasn't a quick skip + if (playInc > 0) { + playbackStatsRepository.recordPlayback( + songId = songId, + durationMs = listened, + timestamp = timestamp + ) + } } private fun accumulateRealtimeListening(session: ActiveSession, nowRealtime: Long) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index ebcdf502f..39ed6c1e4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.service.cast.CastRemotePlaybackState import com.google.android.gms.cast.MediaStatus +import com.theveloper.pixelplay.data.network.NetworkTimeouts import timber.log.Timber import com.theveloper.pixelplay.utils.QueueUtils import com.theveloper.pixelplay.utils.MediaItemBuilder @@ -463,7 +464,7 @@ class PlaybackStateHolder @Inject constructor( remoteSeekUnlockJob?.cancel() remoteSeekUnlockJob = scope?.launch { // Fail-safe: never keep remote seeking lock indefinitely. - delay(1800) + delay(NetworkTimeouts.CAST_SEEK_UNLOCK_MS) castStateHolder.setRemotelySeeking(false) castSession.remoteMediaClient?.requestStatus() } @@ -694,7 +695,7 @@ class PlaybackStateHolder @Inject constructor( if (hasMediaMismatch) { Timber.tag(TAG).v( "Skipping local progress tick due media mismatch (visible=%s, player=%s)", - visibleSong?.id, + visibleSong.id, currentMediaId ) delay(tickMs) 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..bef438288 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 @@ -50,8 +50,6 @@ data class PlayerUiState( val currentFolderSortOption: SortOption = SortOption.FolderNameAZ, 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 10a95ae16..75634ebc7 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 @@ -41,7 +41,6 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.EotStateHolder -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.database.AlbumArtThemeDao import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.data.model.Album @@ -130,7 +129,6 @@ import coil.memory.MemoryCache import dagger.Lazy private const val CAST_LOG_TAG = "PlayerCastTransfer" -private const val ENABLE_FOLDERS_SOURCE_SWITCHING = true private const val MAX_ALBUM_BATCH_SELECTION = 6 private const val SONG_ID_QUERY_CHUNK_SIZE = 900 private const val HOME_MIX_PREVIEW_LIMIT = 48 @@ -205,7 +203,6 @@ private data class AiUiSnapshot( val isGeneratingAiPlaylist: Boolean, val aiStatus: String?, val aiError: String?, - val isGeneratingAiMetadata: Boolean, ) private data class PreparedPlaybackQueueSegments( @@ -526,9 +523,7 @@ 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() @@ -575,35 +570,13 @@ class PlayerViewModel @Inject constructor( initialValue = CarouselStyle.NO_PEEK ) - val hasActiveAiProviderApiKey: StateFlow = combine( - aiPreferencesRepository.aiProvider, - aiPreferencesRepository.geminiApiKey, - aiPreferencesRepository.deepseekApiKey, - aiPreferencesRepository.groqApiKey, - aiPreferencesRepository.mistralApiKey, - aiPreferencesRepository.nvidiaApiKey, - aiPreferencesRepository.kimiApiKey, - aiPreferencesRepository.glmApiKey, - aiPreferencesRepository.openaiApiKey - ) { values -> - val provider = values[0] - val gemini = values[1] - val deepseek = values[2] - val groq = values[3] - val mistral = values[4] - val nvidia = values[5] - val kimi = values[6] - val glm = values[7] - val openai = values[8] - when (provider) { - "DEEPSEEK" -> deepseek.isNotBlank() - "GROQ" -> groq.isNotBlank() - "MISTRAL" -> mistral.isNotBlank() - "NVIDIA" -> nvidia.isNotBlank() - "KIMI" -> kimi.isNotBlank() - "GLM" -> glm.isNotBlank() - "OPENAI" -> openai.isNotBlank() - else -> gemini.isNotBlank() + @OptIn(ExperimentalCoroutinesApi::class) + val hasActiveAiProviderApiKey: StateFlow = aiPreferencesRepository.aiProvider.flatMapLatest { providerStr -> + val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(providerStr) + if (!provider.requiresApiKey) { + flowOf(true) + } else { + aiPreferencesRepository.getApiKey(provider).map { it.isNotBlank() } } }.distinctUntilChanged() .stateIn( @@ -1699,9 +1672,7 @@ class PlayerViewModel @Inject constructor( ?.path ?.path - val effectiveSource = if (!ENABLE_FOLDERS_SOURCE_SWITCHING) { - FolderSource.INTERNAL - } else if (preferredSource == FolderSource.SD_CARD && sdPath == null) { + val effectiveSource = if (preferredSource == FolderSource.SD_CARD && sdPath == null) { FolderSource.INTERNAL } else { preferredSource @@ -1996,19 +1967,15 @@ class PlayerViewModel @Inject constructor( 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 ) }.collect { snapshot -> - _playerUiState.update { - it.copy(isGeneratingAiMetadata = snapshot.isGeneratingAiMetadata) - } + // No-op or update other UI state elements if needed. } } @@ -4555,7 +4522,6 @@ class PlayerViewModel @Inject constructor( } fun setFoldersSource(source: FolderSource) { - if (!ENABLE_FOLDERS_SOURCE_SWITCHING) return viewModelScope.launch { userPreferencesRepository.setFoldersSource(source) } @@ -4666,10 +4632,6 @@ class PlayerViewModel @Inject constructor( aiStateHolder.retryLastPlaylistGeneration() } - fun retryLastMetadataGeneration() { - aiStateHolder.retryLastMetadataGeneration() - } - fun clearQueueExceptCurrent() { mediaController?.let { controller -> val currentSongIndex = controller.currentMediaItemIndex @@ -5390,9 +5352,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, 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 fe4251e8c..d4ffdacb7 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 @@ -28,6 +28,11 @@ import com.theveloper.pixelplay.data.preferences.CollagePattern import com.theveloper.pixelplay.data.preferences.FullPlayerLoadingTweaks import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository import com.theveloper.pixelplay.data.repository.LyricsRepository +import com.theveloper.pixelplay.data.ai.AiDeviceCapabilities +import com.theveloper.pixelplay.data.ai.local.LocalModelCatalog +import com.theveloper.pixelplay.data.ai.local.LocalModelInfo +import com.theveloper.pixelplay.data.ai.local.LocalModelManager +import com.theveloper.pixelplay.data.ai.local.ModelStatus import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.model.LyricsSourcePreference import com.theveloper.pixelplay.data.worker.SyncManager @@ -40,16 +45,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject +import timber.log.Timber import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.preferences.NavBarStyle -import com.theveloper.pixelplay.data.ai.GeminiModel -import com.theveloper.pixelplay.data.ai.provider.AiClientFactory +import com.theveloper.pixelplay.data.ai.AiModel +import com.theveloper.pixelplay.data.ai.AiHandler +import com.theveloper.pixelplay.data.ai.AiNotificationManager import com.theveloper.pixelplay.data.ai.provider.AiProvider import com.theveloper.pixelplay.data.preferences.LaunchTab import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.service.player.HiFiCapabilityChecker import com.theveloper.pixelplay.utils.AppLocaleManager +import com.theveloper.pixelplay.presentation.viewmodel.ColorSchemePair import java.io.File data class SettingsUiState( @@ -79,7 +87,7 @@ data class SettingsUiState( val lyricsSourcePreference: LyricsSourcePreference = LyricsSourcePreference.EMBEDDED_FIRST, val autoScanLrcFiles: Boolean = false, val blockedDirectories: Set = emptySet(), - val availableModels: List = emptyList(), + val availableModels: List = emptyList(), val isLoadingModels: Boolean = false, val modelsFetchError: String? = null, val appRebrandDialogShown: Boolean = false, @@ -108,7 +116,62 @@ data class SettingsUiState( val minTracksPerAlbum: Int = 1, val replayGainEnabled: Boolean = false, val replayGainUseAlbumGain: Boolean = false, - val isSafeTokenLimitEnabled: Boolean = true + val isSafeTokenLimitEnabled: Boolean = true, + // AI Preferences + val aiProvider: String = "GEMINI", + val currentApiKey: String = "", + val currentModel: String = "", + val aiTemperature: Float = 0.7f, + val aiMaxTokens: Int = 2048, + val aiEnableStreaming: Boolean = true, + val aiIncludeContext: Boolean = true, + val maxSongsForContext: Int = AiPreferencesRepository.DEFAULT_MAX_SONGS_FOR_CONTEXT, + val includeLikedSongs: Boolean = true, + val includeDailyMixHistory: Boolean = true, + val includeUserHabits: Boolean = true, + val localMlEnabled: Boolean = false, + val localMlActiveModelId: String = "", + val localMlSelectedModelId: String = "", + val localMlFallbackToRemote: Boolean = true, + val localMlUseGpu: Boolean = false, + val localMlContextSize: Int = AiPreferencesRepository.DEFAULT_LOCAL_MODEL_CONTEXT_SIZE, + val localMlOllamaUrl: String = "https://ollama.ai/api", + val localMlHfToken: String = "", + val localMlSupported: Boolean = true, + val localMlSupportMessage: String = "", + val availableLocalModels: List = emptyList(), + val localModelStatuses: Map = emptyMap(), + // Advanced AI settings + val maxSongsForContextMin: Int = AiPreferencesRepository.MIN_SONGS_FOR_CONTEXT, + val maxSongsForContextMax: Int = AiPreferencesRepository.MAX_SONGS_FOR_CONTEXT, + val aiCacheMaxEntriesMin: Int = AiPreferencesRepository.MIN_CACHE_MAX_ENTRIES, + val aiCacheMaxEntriesMax: Int = AiPreferencesRepository.MAX_CACHE_MAX_ENTRIES, + val aiCacheTtlHoursMin: Int = AiPreferencesRepository.MIN_CACHE_TTL_HOURS, + val aiCacheTtlHoursMax: Int = AiPreferencesRepository.MAX_CACHE_TTL_HOURS, + val aiCacheMaxEntries: Int = AiPreferencesRepository.DEFAULT_CACHE_MAX_ENTRIES, + val aiCacheTtlHours: Int = AiPreferencesRepository.DEFAULT_CACHE_TTL_HOURS, + val aiCacheEnabled: Boolean = true, + val localModelDownloadTimeoutMs: Long = AiPreferencesRepository.DEFAULT_LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS.toLong(), + // Usage analytics + val aiUsageTotalInputTokens: Long = 0L, + val aiUsageTotalOutputTokens: Long = 0L, + val aiUsageTotalApiCalls: Long = 0L, + val aiUsageEstimatedCost: String = "0.00", + // Advanced generation parameters + val aiTopK: Int = AiPreferencesRepository.DEFAULT_TOP_K, + val aiTopP: Float = 0.95f, + val aiRepetitionPenalty: Float = 1.0f, + val aiFrequencyPenalty: Float = 0.0f, + val aiPresencePenalty: Float = 0.0f, + // Telemetry / Data collection + val telemetryIncludeSkipCount: Boolean = false, + val telemetryIncludeCompletionRate: Boolean = false, + val telemetryIncludeSessionDuration: Boolean = false, + val telemetryIncludeTimeOfDay: Boolean = false, + val telemetryIncludeGenreAffinity: Boolean = false, + val telemetryIncludeArtistAffinity: Boolean = false, + val telemetryIncludeReplayCount: Boolean = false, + val telemetryIncludeQueuePatterns: Boolean = false ) data class FailedSongInfo( @@ -170,26 +233,75 @@ private sealed interface SettingsUiUpdate { ) : SettingsUiUpdate } +private sealed interface AiSettingsUpdate { + data class GroupA( + val isSafeTokenLimitEnabled: Boolean, + val localMlEnabled: Boolean, + val localMlActiveModelId: String, + val localMlFallbackToRemote: Boolean, + val localMlUseGpu: Boolean, + val localMlContextSize: Int, + val localMlOllamaUrl: String, + val localMlHfToken: String, + val aiProvider: String, + val currentApiKey: String, + val currentModel: String, + val aiTemperature: Int, + val aiMaxTokens: Int, + val aiEnableStreaming: Boolean, + val aiIncludeContext: Boolean, + val localModelDownloadTimeoutMs: Long, + val localMlSelectedModelId: String, + val aiTopK: Int, + val aiTopP: Int, + val aiRepetitionPenalty: Int, + val aiFrequencyPenalty: Int, + val aiPresencePenalty: Int + ) : AiSettingsUpdate + + data class GroupB( + val aiCacheEnabled: Boolean, + val aiCacheMaxEntries: Int, + val aiCacheTtlHours: Int, + val aiUsageTotalInputTokens: Long, + val aiUsageTotalOutputTokens: Long, + val aiUsageTotalApiCalls: Long, + val aiUsageEstimatedCost: String, + val telemetryIncludeSkipCount: Boolean, + val telemetryIncludeCompletionRate: Boolean, + val telemetryIncludeSessionDuration: Boolean, + val telemetryIncludeTimeOfDay: Boolean, + val telemetryIncludeGenreAffinity: Boolean, + val telemetryIncludeArtistAffinity: Boolean, + val telemetryIncludeReplayCount: Boolean, + val telemetryIncludeQueuePatterns: Boolean + ) : AiSettingsUpdate +} + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class SettingsViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, private val aiPreferencesRepository: AiPreferencesRepository, + private val aiDeviceCapabilities: AiDeviceCapabilities, private val themePreferencesRepository: ThemePreferencesRepository, - private val colorSchemeProcessor: ColorSchemeProcessor, + private val colorSchemeProcessor: com.theveloper.pixelplay.presentation.viewmodel.ColorSchemeProcessor, private val syncManager: SyncManager, - private val aiClientFactory: AiClientFactory, - private val geminiModelService: com.theveloper.pixelplay.data.ai.GeminiModelService, + private val aiHandler: AiHandler, private val aiUsageDao: AiUsageDao, private val lyricsRepository: LyricsRepository, private val musicRepository: MusicRepository, private val backupManager: BackupManager, + private val localMlManager: com.theveloper.pixelplay.data.ai.local.LocalModelManager, + private val notificationManager: AiNotificationManager, @ApplicationContext private val context: Context ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var previousModelStatuses: Map = emptyMap() + // AI Provider State val aiProvider: StateFlow = aiPreferencesRepository.aiProvider .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "GEMINI") @@ -207,69 +319,62 @@ class SettingsViewModel @Inject constructor( .flatMapLatest { provider -> aiPreferencesRepository.getSystemPrompt(AiProvider.fromString(provider)) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) - // Specific Provider StateFlows for UI Compatibility - val geminiApiKey: StateFlow = aiPreferencesRepository.geminiApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val geminiModel: StateFlow = aiPreferencesRepository.geminiModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val geminiSystemPrompt: StateFlow = aiPreferencesRepository.geminiSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) - - val deepseekApiKey: StateFlow = aiPreferencesRepository.deepseekApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val deepseekModel: StateFlow = aiPreferencesRepository.deepseekModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val deepseekSystemPrompt: StateFlow = aiPreferencesRepository.deepseekSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_DEEPSEEK_SYSTEM_PROMPT) - - val groqApiKey: StateFlow = aiPreferencesRepository.groqApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val groqModel: StateFlow = aiPreferencesRepository.groqModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val groqSystemPrompt: StateFlow = aiPreferencesRepository.groqSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_GROQ_SYSTEM_PROMPT) - val mistralApiKey: StateFlow = aiPreferencesRepository.mistralApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val mistralModel: StateFlow = aiPreferencesRepository.mistralModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val mistralSystemPrompt: StateFlow = aiPreferencesRepository.mistralSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_MISTRAL_SYSTEM_PROMPT) - val nvidiaApiKey: StateFlow = aiPreferencesRepository.nvidiaApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val nvidiaModel: StateFlow = aiPreferencesRepository.nvidiaModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val nvidiaSystemPrompt: StateFlow = aiPreferencesRepository.nvidiaSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_NVIDIA_SYSTEM_PROMPT) + // Local Model StateFlows + val availableLocalModels: StateFlow> = _uiState + .map { it.availableLocalModels } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val kimiApiKey: StateFlow = aiPreferencesRepository.kimiApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val kimiModel: StateFlow = aiPreferencesRepository.kimiModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val kimiSystemPrompt: StateFlow = aiPreferencesRepository.kimiSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_KIMI_SYSTEM_PROMPT) + val localModelStatuses: StateFlow> = localMlManager.statusMap + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) - val glmApiKey: StateFlow = aiPreferencesRepository.glmApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val glmModel: StateFlow = aiPreferencesRepository.glmModel + val localMlSelectedModelId: StateFlow = aiPreferencesRepository.localMlSelectedModelId .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val glmSystemPrompt: StateFlow = aiPreferencesRepository.glmSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_GLM_SYSTEM_PROMPT) - val openaiApiKey: StateFlow = aiPreferencesRepository.openaiApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openaiModel: StateFlow = aiPreferencesRepository.openaiModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openaiSystemPrompt: StateFlow = aiPreferencesRepository.openaiSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_OPENAI_SYSTEM_PROMPT) + // Cache configuration StateFlows + val aiCacheEnabled: StateFlow = aiPreferencesRepository.aiCacheEnabled + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) - val openrouterApiKey: StateFlow = aiPreferencesRepository.openrouterApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openrouterModel: StateFlow = aiPreferencesRepository.openrouterModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openrouterSystemPrompt: StateFlow = aiPreferencesRepository.openrouterSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_OPENROUTER_SYSTEM_PROMPT) + val aiCacheMaxEntries: StateFlow = aiPreferencesRepository.aiCacheMaxEntries + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_CACHE_MAX_ENTRIES) + + val aiCacheTtlHours: StateFlow = aiPreferencesRepository.aiCacheTtlHours + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_CACHE_TTL_HOURS) + + val localModelDownloadTimeoutMs: StateFlow = aiPreferencesRepository.localModelDownloadTimeoutMs + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_LOCAL_MODEL_DOWNLOAD_TIMEOUT_MS.toLong()) + + // Usage analytics StateFlows + val aiUsageTotalInputTokens: StateFlow = aiPreferencesRepository.aiUsageTotalInputTokens + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L) + + val aiUsageTotalOutputTokens: StateFlow = aiPreferencesRepository.aiUsageTotalOutputTokens + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L) + + val aiUsageTotalApiCalls: StateFlow = aiPreferencesRepository.aiUsageTotalApiCalls + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L) + + val aiUsageEstimatedCost: StateFlow = aiPreferencesRepository.aiUsageEstimatedCost + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "0.00") + + // Telemetry StateFlows (for DataCollectionCard) + val telemetryIncludeSkipCount: StateFlow = aiPreferencesRepository.telemetryIncludeSkipCount + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeCompletionRate: StateFlow = aiPreferencesRepository.telemetryIncludeCompletionRate + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeSessionDuration: StateFlow = aiPreferencesRepository.telemetryIncludeSessionDuration + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeTimeOfDay: StateFlow = aiPreferencesRepository.telemetryIncludeTimeOfDay + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeGenreAffinity: StateFlow = aiPreferencesRepository.telemetryIncludeGenreAffinity + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeArtistAffinity: StateFlow = aiPreferencesRepository.telemetryIncludeArtistAffinity + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeReplayCount: StateFlow = aiPreferencesRepository.telemetryIncludeReplayCount + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val telemetryIncludeQueuePatterns: StateFlow = aiPreferencesRepository.telemetryIncludeQueuePatterns + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun onAiApiKeyChange(apiKey: String) { viewModelScope.launch { @@ -281,121 +386,63 @@ class SettingsViewModel @Inject constructor( } } - // Specific on-change methods for UI binding - fun onGeminiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GEMINI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GEMINI") - else clearModelsState("GEMINI") - } - } - fun onDeepseekApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.DEEPSEEK, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "DEEPSEEK") - else clearModelsState("DEEPSEEK") - } - } - fun onGroqApiKeyChange(apiKey: String) { + fun onAiSystemPromptChange(prompt: String) { viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GROQ, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GROQ") - else clearModelsState("GROQ") + val provider = AiProvider.fromString(aiProvider.value) + aiPreferencesRepository.setSystemPrompt(provider, prompt) } } - fun onMistralApiKeyChange(apiKey: String) { + + fun resetAiSystemPrompt() { viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.MISTRAL, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "MISTRAL") - else clearModelsState("MISTRAL") + val provider = AiProvider.fromString(aiProvider.value) + aiPreferencesRepository.resetSystemPrompt(provider) } } - fun onNvidiaApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.NVIDIA, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "NVIDIA") - else clearModelsState("NVIDIA") - } + + fun setLocalMlEnabled(enabled: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlEnabled(enabled) } } - fun onKimiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.KIMI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "KIMI") - else clearModelsState("KIMI") - } + + fun setLocalMlActiveModelId(modelId: String) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlActiveModelId(modelId) } } - fun onGlmApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GLM, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GLM") - else clearModelsState("GLM") - } + + fun setLocalMlFallbackToRemote(fallback: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlFallbackToRemote(fallback) } } - fun onOpenAiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OPENAI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OPENAI") - else clearModelsState("OPENAI") - } + + fun setLocalMlUseGpu(enabled: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlUseGpu(enabled) } } - fun onOpenrouterApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OPENROUTER, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OPENROUTER") - else clearModelsState("OPENROUTER") - } + + fun setLocalMlContextSize(size: Int) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlContextSize(size.coerceIn(20, 200)) } } - fun onAiModelChange(model: String) { - viewModelScope.launch { - val provider = AiProvider.fromString(aiProvider.value) - aiPreferencesRepository.setModel(provider, model) - } + fun setLocalMlOllamaUrl(url: String) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlOllamaUrl(url.trim()) } } - fun onGeminiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GEMINI, model) } - fun onDeepseekModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.DEEPSEEK, model) } - fun onGroqModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GROQ, model) } - fun onMistralModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.MISTRAL, model) } - fun onNvidiaModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.NVIDIA, model) } - fun onKimiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.KIMI, model) } - 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 setLocalMlHfToken(token: String) { + viewModelScope.launch { aiPreferencesRepository.setLocalMlHfToken(token.trim()) } + } - fun onAiSystemPromptChange(prompt: String) { - viewModelScope.launch { - val provider = AiProvider.fromString(aiProvider.value) - aiPreferencesRepository.setSystemPrompt(provider, prompt) - } + fun setAiTemperature(temperature: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiTemperature((temperature * 100).toInt()) } } - fun onGeminiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GEMINI, prompt) } - fun onDeepseekSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.DEEPSEEK, prompt) } - fun onGroqSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GROQ, prompt) } - fun onMistralSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.MISTRAL, prompt) } - fun onNvidiaSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.NVIDIA, prompt) } - fun onKimiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.KIMI, prompt) } - 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 setAiMaxTokens(maxTokens: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiMaxTokens(maxTokens) } + } - fun resetAiSystemPrompt() { - viewModelScope.launch { - val provider = AiProvider.fromString(aiProvider.value) - aiPreferencesRepository.resetSystemPrompt(provider) - } + fun setAiEnableStreaming(enabled: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setAiEnableStreaming(enabled) } } - fun resetGeminiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GEMINI) } - fun resetDeepseekSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.DEEPSEEK) } - fun resetGroqSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GROQ) } - fun resetMistralSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.MISTRAL) } - fun resetNvidiaSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.NVIDIA) } - fun resetKimiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.KIMI) } - 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 setAiIncludeContext(enabled: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setAiIncludeContext(enabled) } + } fun clearAiUsageData() { viewModelScope.launch { @@ -406,6 +453,27 @@ class SettingsViewModel @Inject constructor( val isSafeTokenLimitEnabled: StateFlow = aiPreferencesRepository.isSafeTokenLimitEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val localMlEnabled: StateFlow = aiPreferencesRepository.localMlEnabled + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val localMlActiveModelId: StateFlow = aiPreferencesRepository.localMlActiveModelId + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + val localMlFallbackToRemote: StateFlow = aiPreferencesRepository.localMlFallbackToRemote + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val localMlUseGpu: StateFlow = aiPreferencesRepository.localMlUseGpu + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val localMlContextSize: StateFlow = aiPreferencesRepository.localMlContextSize + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_LOCAL_MODEL_CONTEXT_SIZE) + + val localMlOllamaUrl: StateFlow = aiPreferencesRepository.localMlOllamaUrl + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "https://ollama.ai/api") + + val localMlHfToken: StateFlow = aiPreferencesRepository.localMlHfToken + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val recentAiUsage: StateFlow> = aiUsageDao.getRecentUsages(20) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -452,6 +520,37 @@ class SettingsViewModel @Inject constructor( private val _dataTransferEvents = MutableSharedFlow() val dataTransferEvents: SharedFlow = _dataTransferEvents.asSharedFlow() + private fun refreshLocalMlSupport() { + val capabilities = aiDeviceCapabilities.getCapabilities() + val supportedModels = LocalModelCatalog.all.filter { model -> + aiDeviceCapabilities.canRunModel((model.fileSizeBytes / (1024 * 1024)).toInt()) + } + + val supported = capabilities.supportsTflite && supportedModels.isNotEmpty() + val supportMessage = when { + !capabilities.supportsTflite -> context.getString(R.string.settings_ai_local_models_unsupported_tflite) + supportedModels.isEmpty() -> context.getString( + R.string.settings_ai_local_models_unsupported_memory, + capabilities.recommendedModelSizeMb + ) + else -> "" + } + + _uiState.update { + it.copy( + localMlSupported = supported, + localMlSupportMessage = supportMessage, + localMlEnabled = it.localMlEnabled && supported + ) + } + + if (!supported) { + viewModelScope.launch { + aiPreferencesRepository.setLocalMlEnabled(false) + } + } + } + init { viewModelScope.launch { backupManager.getBackupHistory().collect { history -> @@ -484,6 +583,43 @@ class SettingsViewModel @Inject constructor( ) } + refreshLocalMlSupport() + + viewModelScope.launch { + localMlManager.statusMap.collect { statuses -> + statuses.forEach { (id, status) -> + val prev = previousModelStatuses[id] + val info = LocalModelCatalog.byId(id) + val name = info?.displayName ?: id + when { + status is ModelStatus.Downloading && prev !is ModelStatus.Downloading -> { + notificationManager.showProgress( + "Downloading $name", + "${status.progress}% - ${formatBytes(status.bytesDownloaded)} / ${formatBytes(status.totalBytes)}", + status.progress + ) + } + status is ModelStatus.Downloading && prev is ModelStatus.Downloading -> { + val speed = if (status.speedBytesPerSec > 0) " ${formatBytes(status.speedBytesPerSec)}/s" else "" + val eta = if (status.etaSeconds > 0 && status.etaSeconds < 600) " ${formatDuration(status.etaSeconds)} left" else "" + notificationManager.showProgress( + "Downloading $name", + "${status.progress}%${speed}${eta}", + status.progress + ) + } + status is ModelStatus.Ready && prev !is ModelStatus.Ready -> { + notificationManager.showCompletion("$name downloaded", "Model ready to use") + } + status is ModelStatus.Error && prev !is ModelStatus.Error -> { + notificationManager.showError("Download failed", "$name: ${status.message}") + } + } + } + previousModelStatuses = statuses + } + } + // Consolidated collectors using combine() to reduce coroutine overhead // Instead of 20 separate coroutines, we use 2 combined flows @@ -679,13 +815,239 @@ class SettingsViewModel @Inject constructor( } } + // Group A: AI Core + Local ML — consolidated into 1 combine() to replace ~17 individual coroutines + viewModelScope.launch { + combine( + aiPreferencesRepository.isSafeTokenLimitEnabled, + aiPreferencesRepository.localMlEnabled, + aiPreferencesRepository.localMlActiveModelId, + aiPreferencesRepository.localMlFallbackToRemote, + aiPreferencesRepository.localMlUseGpu, + aiPreferencesRepository.localMlContextSize, + aiPreferencesRepository.localMlOllamaUrl, + aiPreferencesRepository.localMlHfToken, + aiProvider, + currentAiApiKey, + currentAiModel, + aiPreferencesRepository.aiTemperature, + aiPreferencesRepository.aiMaxTokens, + aiPreferencesRepository.aiEnableStreaming, + aiPreferencesRepository.aiIncludeContext, + aiPreferencesRepository.localModelDownloadTimeoutMs, + aiPreferencesRepository.localMlSelectedModelId, + aiPreferencesRepository.aiTopK, + aiPreferencesRepository.aiTopP, + aiPreferencesRepository.aiRepetitionPenalty, + aiPreferencesRepository.aiFrequencyPenalty, + aiPreferencesRepository.aiPresencePenalty + ) { values -> + AiSettingsUpdate.GroupA( + isSafeTokenLimitEnabled = values[0] as Boolean, + localMlEnabled = values[1] as Boolean, + localMlActiveModelId = values[2] as String, + localMlFallbackToRemote = values[3] as Boolean, + localMlUseGpu = values[4] as Boolean, + localMlContextSize = values[5] as Int, + localMlOllamaUrl = values[6] as String, + localMlHfToken = values[7] as String, + aiProvider = values[8] as String, + currentApiKey = values[9] as String, + currentModel = values[10] as String, + aiTemperature = values[11] as Int, + aiMaxTokens = values[12] as Int, + aiEnableStreaming = values[13] as Boolean, + aiIncludeContext = values[14] as Boolean, + localModelDownloadTimeoutMs = values[15] as Long, + localMlSelectedModelId = values[16] as String, + aiTopK = values[17] as Int, + aiTopP = values[18] as Int, + aiRepetitionPenalty = values[19] as Int, + aiFrequencyPenalty = values[20] as Int, + aiPresencePenalty = values[21] as Int + ) + }.collect { update -> + _uiState.update { state -> + state.copy( + isSafeTokenLimitEnabled = update.isSafeTokenLimitEnabled, + localMlEnabled = update.localMlEnabled, + localMlActiveModelId = update.localMlActiveModelId, + localMlFallbackToRemote = update.localMlFallbackToRemote, + localMlUseGpu = update.localMlUseGpu, + localMlContextSize = update.localMlContextSize, + localMlOllamaUrl = update.localMlOllamaUrl, + localMlHfToken = update.localMlHfToken, + aiProvider = update.aiProvider, + currentApiKey = update.currentApiKey, + currentModel = update.currentModel, + aiTemperature = update.aiTemperature / 100f, + aiMaxTokens = update.aiMaxTokens, + aiEnableStreaming = update.aiEnableStreaming, + aiIncludeContext = update.aiIncludeContext, + localModelDownloadTimeoutMs = update.localModelDownloadTimeoutMs, + localMlSelectedModelId = update.localMlSelectedModelId, + aiTopK = update.aiTopK, + aiTopP = update.aiTopP / 100f, + aiRepetitionPenalty = update.aiRepetitionPenalty / 100f, + aiFrequencyPenalty = update.aiFrequencyPenalty / 100f, + aiPresencePenalty = update.aiPresencePenalty / 100f + ) + } + } + } + + // Group B: Cache + Usage + Telemetry — consolidated into 1 combine() to replace ~15 individual coroutines + viewModelScope.launch { + combine( + aiPreferencesRepository.aiCacheEnabled, + aiPreferencesRepository.aiCacheMaxEntries, + aiPreferencesRepository.aiCacheTtlHours, + aiPreferencesRepository.aiUsageTotalInputTokens, + aiPreferencesRepository.aiUsageTotalOutputTokens, + aiPreferencesRepository.aiUsageTotalApiCalls, + aiPreferencesRepository.aiUsageEstimatedCost, + aiPreferencesRepository.telemetryIncludeSkipCount, + aiPreferencesRepository.telemetryIncludeCompletionRate, + aiPreferencesRepository.telemetryIncludeSessionDuration, + aiPreferencesRepository.telemetryIncludeTimeOfDay, + aiPreferencesRepository.telemetryIncludeGenreAffinity, + aiPreferencesRepository.telemetryIncludeArtistAffinity, + aiPreferencesRepository.telemetryIncludeReplayCount, + aiPreferencesRepository.telemetryIncludeQueuePatterns + ) { values -> + AiSettingsUpdate.GroupB( + aiCacheEnabled = values[0] as Boolean, + aiCacheMaxEntries = values[1] as Int, + aiCacheTtlHours = values[2] as Int, + aiUsageTotalInputTokens = values[3] as Long, + aiUsageTotalOutputTokens = values[4] as Long, + aiUsageTotalApiCalls = values[5] as Long, + aiUsageEstimatedCost = values[6] as String, + telemetryIncludeSkipCount = values[7] as Boolean, + telemetryIncludeCompletionRate = values[8] as Boolean, + telemetryIncludeSessionDuration = values[9] as Boolean, + telemetryIncludeTimeOfDay = values[10] as Boolean, + telemetryIncludeGenreAffinity = values[11] as Boolean, + telemetryIncludeArtistAffinity = values[12] as Boolean, + telemetryIncludeReplayCount = values[13] as Boolean, + telemetryIncludeQueuePatterns = values[14] as Boolean + ) + }.collect { update -> + _uiState.update { state -> + state.copy( + aiCacheEnabled = update.aiCacheEnabled, + aiCacheMaxEntries = update.aiCacheMaxEntries, + aiCacheTtlHours = update.aiCacheTtlHours, + aiUsageTotalInputTokens = update.aiUsageTotalInputTokens, + aiUsageTotalOutputTokens = update.aiUsageTotalOutputTokens, + aiUsageTotalApiCalls = update.aiUsageTotalApiCalls, + aiUsageEstimatedCost = update.aiUsageEstimatedCost, + telemetryIncludeSkipCount = update.telemetryIncludeSkipCount, + telemetryIncludeCompletionRate = update.telemetryIncludeCompletionRate, + telemetryIncludeSessionDuration = update.telemetryIncludeSessionDuration, + telemetryIncludeTimeOfDay = update.telemetryIncludeTimeOfDay, + telemetryIncludeGenreAffinity = update.telemetryIncludeGenreAffinity, + telemetryIncludeArtistAffinity = update.telemetryIncludeArtistAffinity, + telemetryIncludeReplayCount = update.telemetryIncludeReplayCount, + telemetryIncludeQueuePatterns = update.telemetryIncludeQueuePatterns + ) + } + } + } + + + // Load available local models + loadLocalModels() + } + + private fun loadLocalModels() { viewModelScope.launch { - aiPreferencesRepository.isSafeTokenLimitEnabled.collect { enabled -> - _uiState.update { it.copy(isSafeTokenLimitEnabled = enabled) } + val localModels = LocalModelCatalog.all.filter { model -> + val modelSizeMb = (model.fileSizeBytes / (1024 * 1024)).toInt() + aiDeviceCapabilities.canRunModel(modelSizeMb) || modelSizeMb <= 50 + } + _uiState.update { it.copy(availableLocalModels = localModels) } + + // Seed statusMap with already-installed models + localModels.filter { localMlManager.isInstalled(it.id) }.forEach { model -> + val validated = localMlManager.validateModelFile(model.id) + if (validated is LocalModelManager.ValidationResult.Ok) { + localMlManager.seedStatus(model.id, ModelStatus.Ready) + } else if (validated is LocalModelManager.ValidationResult.SizeMismatch) { + Timber.w("Model size mismatch: ${model.id} (${validated.actual} vs ${validated.expected})") + localMlManager.deleteModel(model.id) + } + } + + // Collect local model status changes + localMlManager.statusMap.collect { statuses: Map -> + _uiState.update { it.copy(localModelStatuses = statuses) } } } } + fun downloadLocalModel(modelInfo: LocalModelInfo) { + val mb = modelInfo.fileSizeBytes / (1024 * 1024) + notificationManager.showProgress( + "Downloading ${modelInfo.displayName}", + "Starting download... ($mb MB)", + 0 + ) + localMlManager.downloadModel(modelInfo) + } + + fun cancelDownloadModel(modelId: String) { + localMlManager.cancelDownload(modelId) + val info = LocalModelCatalog.byId(modelId) + if (info != null) { + notificationManager.showInfo("Download cancelled", "${info.displayName} download cancelled") + } + } + + fun deleteLocalModel(modelId: String) { + viewModelScope.launch { + localMlManager.deleteModel(modelId) + val currentStatuses = _uiState.value.localModelStatuses.toMutableMap() + currentStatuses[modelId] = ModelStatus.NotDownloaded + _uiState.update { it.copy(localModelStatuses = currentStatuses) } + + // Clear active model if deleted + if (_uiState.value.localMlActiveModelId == modelId) { + aiPreferencesRepository.setLocalMlActiveModelId("") + _uiState.update { it.copy(localMlActiveModelId = "") } + } + } + } + + fun selectLocalModel(modelId: String) { + viewModelScope.launch { + aiPreferencesRepository.setLocalMlActiveModelId(modelId) + localMlManager.setActiveModel(modelId) + _uiState.update { it.copy(localMlActiveModelId = modelId) } + } + } + + fun importLocalModel(uri: Uri) { + viewModelScope.launch { + val modelId = "user_imported_${System.currentTimeMillis()}" + localMlManager.importModel(uri, modelId).onSuccess { file -> + val currentStatuses = _uiState.value.localModelStatuses.toMutableMap() + currentStatuses[modelId] = ModelStatus.Ready + _uiState.update { it.copy(localModelStatuses = currentStatuses) } + }.onFailure { error -> + val currentStatuses = _uiState.value.localModelStatuses.toMutableMap() + currentStatuses[modelId] = ModelStatus.Error(error.message ?: "Import failed") + _uiState.update { it.copy(localModelStatuses = currentStatuses) } + } + } + } + + fun onLocalMlUseGpuChange(enabled: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setLocalMlUseGpu(enabled) + _uiState.update { it.copy(localMlUseGpu = enabled) } + } + } + fun setAppRebrandDialogShown(wasShown: Boolean) { viewModelScope.launch { userPreferencesRepository.setAppRebrandDialogShown(wasShown) @@ -1006,7 +1368,103 @@ class SettingsViewModel @Inject constructor( } } + fun setMaxSongsForContext(maxSongs: Int) { + viewModelScope.launch { + aiPreferencesRepository.setMaxSongsForContext(maxSongs) + } + } + + fun setIncludeLikedSongs(include: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setIncludeLikedSongs(include) + } + } + + fun setIncludeDailyMixHistory(include: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setIncludeDailyMixHistory(include) + } + } + + fun setIncludeUserHabits(include: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setIncludeUserHabits(include) + } + } + + fun setLocalMlSelectedModelId(modelId: String) { + viewModelScope.launch { + aiPreferencesRepository.setLocalMlSelectedModelId(modelId) + } + } + fun setLocalModelDownloadTimeoutMs(timeoutMs: Long) { + viewModelScope.launch { + aiPreferencesRepository.setLocalModelDownloadTimeoutMs(timeoutMs) + } + } + + fun setAiCacheEnabled(enabled: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setAiCacheEnabled(enabled) + } + } + + fun setAiCacheMaxEntries(maxEntries: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiCacheMaxEntries(maxEntries) + } + } + + fun setAiCacheTtlHours(ttlHours: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiCacheTtlHours(ttlHours) + } + } + + fun getAiUsageStats(): Pair { + return Pair(aiUsageTotalInputTokens.value, aiUsageTotalOutputTokens.value) + } + + fun clearAiUsageMetrics() { + viewModelScope.launch { + aiPreferencesRepository.clearAiUsageMetrics() + } + } + + fun setPerModelTemperature(modelName: String, temperature: Float) { + viewModelScope.launch { + aiPreferencesRepository.setPerModelTemperature(modelName, (temperature * 100).toInt()) + } + } + + fun clearPerModelTemperature(modelName: String) { + viewModelScope.launch { + aiPreferencesRepository.clearPerModelTemperature(modelName) + } + } + + fun setPerModelMaxTokens(modelName: String, tokens: Int) { + viewModelScope.launch { + aiPreferencesRepository.setPerModelMaxTokens(modelName, tokens) + } + } + + fun clearPerModelMaxTokens(modelName: String) { + viewModelScope.launch { + aiPreferencesRepository.clearPerModelMaxTokens(modelName) + } + } + + fun setProviderTimeout(provider: AiProvider, timeoutMs: Long) { + viewModelScope.launch { + aiPreferencesRepository.setProviderTimeout(provider, timeoutMs) + } + } + + fun getLocalModelDownloadUrl(modelId: String): String? { + return availableLocalModels.value.find { it.id == modelId }?.downloadUrl + } /** * Performs a full library rescan - rescans all files from scratch. @@ -1083,9 +1541,6 @@ class SettingsViewModel @Inject constructor( ) } - // Small delay to let the provider preference propagate to StateFlows - delay(100) - // Fetch models for the newly selected provider if we have an API key val apiKey = aiPreferencesRepository.getApiKey(AiProvider.fromString(provider)).first() @@ -1095,6 +1550,91 @@ class SettingsViewModel @Inject constructor( } } + fun onAiModelChange(model: String) { + viewModelScope.launch { + val provider = AiProvider.fromString(aiProvider.value) + aiPreferencesRepository.setModel(provider, model) + _uiState.update { it.copy(currentModel = model) } + } + } + + fun onAiTemperatureChange(temperature: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiTemperature(temperature) + _uiState.update { it.copy(aiTemperature = temperature / 100f) } + } + } + + fun onAiTopKChange(value: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiTopK(value) + _uiState.update { it.copy(aiTopK = value) } + } + } + + fun onAiTopPChange(value: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiTopP(value) + _uiState.update { it.copy(aiTopP = value / 100f) } + } + } + + fun onAiRepetitionPenaltyChange(value: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiRepetitionPenalty(value) + _uiState.update { it.copy(aiRepetitionPenalty = value / 100f) } + } + } + + fun onAiFrequencyPenaltyChange(value: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiFrequencyPenalty(value) + _uiState.update { it.copy(aiFrequencyPenalty = value / 100f) } + } + } + + fun onAiPresencePenaltyChange(value: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiPresencePenalty(value) + _uiState.update { it.copy(aiPresencePenalty = value / 100f) } + } + } + + fun onAiMaxTokensChange(maxTokens: Int) { + viewModelScope.launch { + aiPreferencesRepository.setAiMaxTokens(maxTokens) + _uiState.update { it.copy(aiMaxTokens = maxTokens) } + } + } + + fun onMaxSongsForContextChange(size: Int) { + viewModelScope.launch { + aiPreferencesRepository.setMaxSongsForContext(size) + _uiState.update { it.copy(maxSongsForContext = size) } + } + } + + fun onIncludeLikedSongsChange(include: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setIncludeLikedSongs(include) + _uiState.update { it.copy(includeLikedSongs = include) } + } + } + + fun onIncludeDailyMixHistoryChange(include: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setIncludeDailyMixHistory(include) + _uiState.update { it.copy(includeDailyMixHistory = include) } + } + } + + fun onIncludeUserHabitsChange(include: Boolean) { + viewModelScope.launch { + aiPreferencesRepository.setIncludeUserHabits(include) + _uiState.update { it.copy(includeUserHabits = include) } + } + } + fun loadModelsForCurrentProvider() { viewModelScope.launch { if (_uiState.value.isLoadingModels) return@launch @@ -1109,6 +1649,19 @@ class SettingsViewModel @Inject constructor( } } + private fun formatBytes(bytes: Long): String = when { + bytes >= 1_000_000_000 -> "%.1fGB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1fMB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.1fKB".format(bytes / 1_000.0) + else -> "$bytes B" + } + + private fun formatDuration(seconds: Long): String = when { + seconds < 60 -> "${seconds}s" + seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" + else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" + } + private fun clearModelsState(provider: String) { _uiState.update { it.copy( @@ -1126,16 +1679,8 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(isLoadingModels = true, modelsFetchError = null) } try { val provider = AiProvider.fromString(providerName) - val models = if (provider == AiProvider.GEMINI) { - geminiModelService.fetchAvailableModels(apiKey).getOrThrow() - } else { - val aiClient = aiClientFactory.createClient(provider, apiKey) - aiClient.getAvailableModels(apiKey) - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - .map { com.theveloper.pixelplay.data.ai.GeminiModel(it, formatModelDisplayName(it)) } - } + val modelsResult = aiHandler.fetchAvailableModels(provider, apiKey) + val models = modelsResult.getOrThrow() _uiState.update { it.copy( @@ -1163,18 +1708,6 @@ class SettingsViewModel @Inject constructor( } } - private fun formatModelDisplayName(modelName: String): String { - return modelName - .removePrefix("models/") - .replace('-', ' ') - .replace('_', ' ') - .split(' ') - .joinToString(" ") { token -> - token.lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } - } - } - - fun setNavBarCornerRadius(radius: Int) { viewModelScope.launch { userPreferencesRepository.setNavBarCornerRadius(radius) } } @@ -1350,4 +1883,29 @@ class SettingsViewModel @Inject constructor( } } + // Telemetry change handlers + fun onTelemetrySkipCountChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeSkipCount(v) } + } + fun onTelemetryCompletionRateChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeCompletionRate(v) } + } + fun onTelemetrySessionDurationChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeSessionDuration(v) } + } + fun onTelemetryTimeOfDayChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeTimeOfDay(v) } + } + fun onTelemetryGenreAffinityChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeGenreAffinity(v) } + } + fun onTelemetryArtistAffinityChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeArtistAffinity(v) } + } + fun onTelemetryReplayCountChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeReplayCount(v) } + } + fun onTelemetryQueuePatternsChange(v: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setTelemetryIncludeQueuePatterns(v) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10a198aa9..f41701b8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,7 +242,7 @@ (Mixed values) (Optional - leave empty to skip) Successfully updated %d songs - Updated %d of %d songs. Some files could not be edited. + Updated %1$d of %2$d songs. Some files could not be edited. Failed to update songs @@ -251,4 +251,36 @@ Set Cover Art for All Remove All Cover Art (Multiple different covers) + AI Data & Privacy + Control what information is shared with AI providers + Context Song Limit + Maximum number of songs to send for personalization + Include Liked Songs + Share your favorites to improve recommendations + Include Daily Mix History + Share recent mix choices + Include User Habits + Share listening patterns and time preferences + Model Catalog + Download models for offline use + Download + Delete + Downloaded + Downloading… + Warning: Disabling hardware lock may cause crashes on unsupported devices + Hardware Lock + Restrict local models to verified hardware + Temperature + Creativity level (0 = precise, 2 = creative) + Max Tokens + Maximum response length + Enable Streaming + Stream responses as they generate + Include Context + Add song history to prompts + AI Provider + Model + API Key + Developer Options + Advanced AI settings and debugging diff --git a/app/src/main/res/values/strings_presentation_batch_e.xml b/app/src/main/res/values/strings_presentation_batch_e.xml index abe189e89..12c80da48 100644 --- a/app/src/main/res/values/strings_presentation_batch_e.xml +++ b/app/src/main/res/values/strings_presentation_batch_e.xml @@ -67,6 +67,10 @@ Curation engine Energy Controls the intensity and tempo of songs. 1 = calm/slow, 5 = high-energy/fast. + Tempo + Preferred song pace. 1 = slow ballads, 5 = fast/high BPM. + Focus on recent listening + Prefer songs you\'ve played recently for a familiar blend. Discovery Controls how familiar the selections are. 1 = your most played favorites, 5 = rarely played deep cuts. Min songs @@ -104,7 +108,9 @@ Energy level target: %1$d/5. Discovery target: %1$d/5 where 1 is familiar and 5 is deep cuts. Prioritize songs closer to listener favorites when possible. + Prefer songs the listener has played recently (last 24h). Avoid explicit lyrics whenever alternatives exist. + Tempo target: %1$d/5 where 1 is slow/ballad and 5 is fast/high BPM. Keep transitions smooth and avoid repetitive artist clustering. diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 2119b641d..b930ce45d 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -199,6 +199,22 @@ AI Usage Report Total Consumption %1$s tokens tracking\nPrompt: %2$s | Output: %3$s | Thought: %4$s + Local AI Models + Enable Local AI + Use on-device or local server models before remote providers. + Local Model ID + Enter installed model name + Fallback to Remote + Use remote AI when the selected local model is unavailable. + Use GPU Acceleration + Enable GPU execution for supported local models. + Local models are unavailable on this device. + Local models require TensorFlow Lite runtime support. + Device does not meet the minimum local model threshold (%1$d MB recommended). + Ollama Server URL + https://ollama.ai/api + Hugging Face Token + Enter Hugging Face access token Create Backup 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 d41ed6be9..28511e677 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 @@ -177,7 +177,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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d8e45488..0953d3912 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,10 +80,19 @@ javax-inject = "1" ksp = "2.3.8" smoothCornerRectAndroidCompose = "v1.0.0" spleeterAndroidIos = "1.0.2" + +# TensorFlow Lite & Local AI tensorflowLite = "2.17.0" +tensorflowLiteTask = "0.4.4" tensorflowLiteSelectTfOps = "2.16.1" tensorflowLiteSelectTfOpsVersion = "2.16.1" tensorflowLiteSupport = "0.5.0" +tensorflowLiteGpu = "2.17.0" + +# ML Kit +mlkitTranslate = "17.0.3" +mlkitLanguageId = "17.0.6" + wavySlider = "2.2.0" workRuntimeKtx = "2.11.2" composeTesting = "1.0.0-alpha03" @@ -201,9 +210,11 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss smooth-corner-rect-android-compose = { module = "com.github.racra:smooth-corner-rect-android-compose", version.ref = "smoothCornerRectAndroidCompose" } spleeter-android-ios = { module = "com.github.FaceOnLive:Spleeter-Android-iOS", version.ref = "spleeterAndroidIos" } tensorflow-lite = { module = "org.tensorflow:tensorflow-lite", version.ref = "tensorflowLite" } -#tensorflow-lite-select-tf-ops = { module = "org.tensorflow:tensorflow-lite-select-tf-ops", version.ref = "tensorflowLiteSelectTfOps" } -tensorflow-lite-select-tf-ops = { module = "org.tensorflow:tensorflow-lite-select-tf-ops", version.ref = "tensorflowLiteSelectTfOpsVersion" } +tensorflow-lite-gpu = { module = "org.tensorflow:tensorflow-lite-gpu", version.ref = "tensorflowLiteGpu" } tensorflow-lite-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflowLiteSupport" } +tensorflow-lite-task-text = { module = "org.tensorflow:tensorflow-lite-task-text", version.ref = "tensorflowLiteTask" } +mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkitTranslate" } +mlkit-language-id = { module = "com.google.mlkit:language-id", version.ref = "mlkitLanguageId" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } wavy-slider = { module = "ir.mahozad.multiplatform:wavy-slider", version.ref = "wavySlider" } checker-qual = { group = "org.checkerframework", name = "checker-qual", version.ref = "checkerframework" }