feat: implement endless playback recommendations in DeviceAudioPlayerQueue

This commit is contained in:
Kingkor Roy Tirtho 2026-07-03 19:21:57 +06:00
parent d7fa3d926d
commit 6d8b9a575f
2 changed files with 80 additions and 1 deletions

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -36,12 +37,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class DeviceAudioPlayerQueue( class DeviceAudioPlayerQueue(
private val audioPlayer: AudioPlayer, private val audioPlayer: AudioPlayer,
private val settingsViewModel: SettingsViewModel, private val settingsViewModel: SettingsViewModel,
private val repository: AudioPlayerQueueRepository, private val repository: AudioPlayerQueueRepository,
private val pluginManager: PluginManager,
) : AudioPlayerQueue, KoinComponent { ) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -98,6 +101,27 @@ class DeviceAudioPlayerQueue(
persistQueueStateIfReady() persistQueueStateIfReady()
} }
} }
scope.launch {
combine(
audioPlayer.currentMediaItemFlow,
audioPlayer.playlistFlow,
audioPlayer.loopStateFlow,
) { currentMedia, playlist, loopState ->
Triple(currentMedia, playlist, loopState)
}.collect { (currentMedia, playlist, loopState) ->
if (currentMedia == null || playlist.isEmpty()) return@collect
if (loopState == LoopState.ALL) return@collect
val currentIndex = playlist.indexOfFirst { it.url == currentMedia.url }
if (currentIndex < 0) return@collect
val tracksAfterCurrent = playlist.size - currentIndex - 1
if (tracksAfterCurrent <= 0) {
handleQueueCompletion()
}
}
}
} }
override suspend fun load( override suspend fun load(
@ -281,6 +305,61 @@ class DeviceAudioPlayerQueue(
return collectionHistoryState.value return collectionHistoryState.value
} }
private var isFetchingRecommendations = false
private suspend fun handleQueueCompletion() {
if (isFetchingRecommendations) return
val settings = settingsViewModel.settingsState.value ?: return
if (!settings.enableEndlessPlayback) return
val queue = queueFlow.value
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
if (streamingTracks.isEmpty()) return
val seedTracks = if (streamingTracks.size <= 5) {
streamingTracks
} else {
val shuffled = streamingTracks.shuffled(Random)
shuffled.take(5)
}
val seedTrackIds = seedTracks.map { it.track.id }
if (seedTrackIds.isEmpty()) return
isFetchingRecommendations = true
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
val metadataService = pluginManager.selectedMetadataPlugin.value ?: run {
logger.w { "Endless playback: no metadata plugin available" }
isFetchingRecommendations = false
return
}
try {
val recommendations = metadataService.use {
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
}
if (recommendations.isEmpty()) {
logger.w { "Endless playback: no recommendations returned" }
isFetchingRecommendations = false
return
}
val newEntries = recommendations.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
addAllToQueue(newEntries)
} catch (e: Exception) {
logger.e(e) { "Endless playback: failed to fetch recommendations" }
} finally {
isFetchingRecommendations = false
}
}
private suspend fun restorePersistedState() { private suspend fun restorePersistedState() {
val state = repository.getPersistedState() val state = repository.getPersistedState()
if (state == null) { if (state == null) {

View File

@ -163,7 +163,7 @@ val sharedModules = module {
} }
singleOf(::AudioPlayerQueueRepository) singleOf(::AudioPlayerQueueRepository)
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get())
} }
factory { (tag: String?) -> factory { (tag: String?) ->