/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package dev.krtirtho.spotube.media
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.flow.first
class MediaBrowseHelper(
private val pluginManager: PluginManager,
private val homeScreenRepository: HomeScreenRepository,
private val libraryRepository: LibraryRepository,
private val playlistRepository: PlaylistRepository,
private val albumRepository: AlbumRepository,
private val savedTracksRepository: SavedTracksRepository,
private val searchRepository: SearchRepository,
private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository,
) {
companion object {
const val MEDIA_ID_ROOT = "root"
const val MEDIA_ID_BROWSE = "browse"
const val MEDIA_ID_BROWSE_GENRE = "browse:genre"
const val MEDIA_ID_BROWSE_FEATURED = "browse:featured"
const val MEDIA_ID_LIBRARY = "library"
const val MEDIA_ID_SAVED_PLAYLISTS = "library:playlists"
const val MEDIA_ID_SAVED_ALBUMS = "library:albums"
const val MEDIA_ID_SAVED_ARTISTS = "library:artists"
const val MEDIA_ID_SAVED_TRACKS = "library:tracks"
const val MEDIA_ID_PLAYLIST = "playlist"
const val MEDIA_ID_ALBUM = "album"
const val MEDIA_ID_ARTIST = "artist"
const val MEDIA_ID_ARTIST_TRACKS = "artist:tracks"
const val MEDIA_ID_ARTIST_ALBUMS = "artist:albums"
const val MEDIA_ID_TRACK = "track"
const val MEDIA_ID_QUEUE = "queue"
private const val CONTENT_STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val CONTENT_STYLE_GRID = 2
private const val CONTENT_STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
}
fun buildRootItem(): MediaItem {
return MediaItem.Builder()
.setMediaId(MEDIA_ID_ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Spotube")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build()
}
suspend fun getChildren(parentId: String): List {
Log.d("MediaBrowseHelper", "Getting children for parentId: $parentId")
return when {
parentId == MEDIA_ID_ROOT -> buildRootChildren()
parentId == MEDIA_ID_BROWSE -> buildBrowseItems()
parentId == MEDIA_ID_BROWSE_FEATURED -> buildFeaturedItems()
parentId.startsWith("$MEDIA_ID_BROWSE_GENRE:") -> {
val genreId = parentId.removePrefix("$MEDIA_ID_BROWSE_GENRE:")
buildBrowseGenreItems(genreId)
}
parentId == MEDIA_ID_LIBRARY -> buildLibraryRootChildren()
parentId == MEDIA_ID_SAVED_PLAYLISTS -> buildSavedPlaylistsItems()
parentId == MEDIA_ID_SAVED_ALBUMS -> buildSavedAlbumsItems()
parentId == MEDIA_ID_SAVED_ARTISTS -> buildSavedArtistsItems()
parentId == MEDIA_ID_SAVED_TRACKS -> buildSavedTracksItems()
parentId.startsWith("$MEDIA_ID_PLAYLIST:") -> {
val playlistId = parentId.removePrefix("$MEDIA_ID_PLAYLIST:")
buildPlaylistTracksItems(playlistId)
}
parentId.startsWith("$MEDIA_ID_ALBUM:") -> {
val albumId = parentId.removePrefix("$MEDIA_ID_ALBUM:")
buildAlbumTracksItems(albumId)
}
parentId.startsWith("$MEDIA_ID_ARTIST:") -> {
val artistId = parentId.removePrefix("$MEDIA_ID_ARTIST:")
buildArtistOverviewItems(artistId)
}
parentId.startsWith("$MEDIA_ID_ARTIST_TRACKS:") -> {
val artistId = parentId.removePrefix("$MEDIA_ID_ARTIST_TRACKS:")
buildArtistTracksItems(artistId)
}
parentId.startsWith("$MEDIA_ID_ARTIST_ALBUMS:") -> {
val artistId = parentId.removePrefix("$MEDIA_ID_ARTIST_ALBUMS:")
buildArtistAlbumsItems(artistId)
}
parentId == MEDIA_ID_QUEUE -> buildQueueItems()
else -> emptyList()
}
}
suspend fun search(query: String): List {
val results = try {
searchRepository.searchAll(query)
} catch (_: Exception) {
return emptyList()
}
val items = mutableListOf()
for (result in results) {
val item = when (result) {
is MetadataSearchResult.Track ->
buildTrackMediaItem(result.data)
is MetadataSearchResult.Album ->
buildAlbumMediaItem(result.data)
is MetadataSearchResult.Artist ->
buildArtistMediaItem(result.data)
is MetadataSearchResult.Playlist ->
buildPlaylistMediaItem(result.data)
is MetadataSearchResult.User -> null
}
if (item != null) {
items.add(item)
}
}
return items
}
suspend fun resolveTrackById(trackId: String): MetadataTrack? {
return getTrackById(trackId)
}
suspend fun resolveTrackToMediaItem(trackId: String): MediaItem? {
val track = getTrackById(trackId) ?: return null
val url = buildStreamingUrl(track.id) ?: return null
return MediaItem.Builder()
.setMediaId("$MEDIA_ID_TRACK:${track.id}")
.setUri(url)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title)
.setArtist(track.artists.joinToString(", ") { it.name })
.setAlbumTitle(track.album?.title)
.setArtworkUri(track.album?.thumbnails?.firstOrNull()?.url?.let { Uri.parse(it) })
.setTrackNumber(track.trackNumber)
.setIsPlayable(true)
.setIsBrowsable(false)
.build()
)
.build()
}
suspend fun resolveAndPlayTrack(track: MetadataTrack) {
val entry = QueueEntry.StreamingTrack(track = track, url = "")
audioPlayerQueue.load(listOf(entry), autoPlay = true, startPosition = 0)
}
suspend fun resolveAndPlayFromMediaId(mediaId: String): Boolean {
return when {
mediaId.startsWith("$MEDIA_ID_TRACK:") -> {
val trackId = mediaId.removePrefix("$MEDIA_ID_TRACK:")
val track = getTrackById(trackId) ?: return false
resolveAndPlayTrack(track)
true
}
mediaId.startsWith("$MEDIA_ID_PLAYLIST:") -> {
val playlistId = mediaId.removePrefix("$MEDIA_ID_PLAYLIST:")
collectionPlaybackHelper.playPlaylist(playlistId)
true
}
mediaId.startsWith("$MEDIA_ID_ALBUM:") -> {
val albumId = mediaId.removePrefix("$MEDIA_ID_ALBUM:")
collectionPlaybackHelper.playAlbum(albumId)
true
}
mediaId == MEDIA_ID_SAVED_TRACKS -> {
collectionPlaybackHelper.playSavedTracks()
true
}
mediaId.startsWith("$MEDIA_ID_ARTIST_TRACKS:") -> {
val artistId = mediaId.removePrefix("$MEDIA_ID_ARTIST_TRACKS:")
playArtistTracks(artistId)
true
}
else -> false
}
}
private fun buildRootChildren(): List {
return listOf(
MediaItem.Builder()
.setMediaId(MEDIA_ID_BROWSE)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Browse")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId(MEDIA_ID_LIBRARY)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Library")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId(MEDIA_ID_QUEUE)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Now Playing")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
)
}
private suspend fun buildBrowseItems(): List {
val gridExtras = Bundle().apply { putInt(CONTENT_STYLE_BROWSABLE, CONTENT_STYLE_GRID) }
val items = mutableListOf()
items.add(
MediaItem.Builder()
.setMediaId(MEDIA_ID_BROWSE_FEATURED)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Featured")
.setIsBrowsable(true)
.setIsPlayable(false)
.setExtras(gridExtras)
.build()
)
.build()
)
val genres = try {
homeScreenRepository.genres() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
for (genre in genres) {
items.add(
MediaItem.Builder()
.setMediaId("$MEDIA_ID_BROWSE_GENRE:${genre.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(genre.name)
.setIsBrowsable(true)
.setIsPlayable(false)
.setExtras(gridExtras)
.build()
)
.build()
)
}
return items
}
private suspend fun buildFeaturedItems(): List {
val featured = try {
homeScreenRepository.featuredItems() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return featured.mapNotNull { item -> browseItemToMediaItem(item) }
}
private suspend fun buildBrowseGenreItems(genreId: String): List {
val sections = try {
homeScreenRepository.list(genreId)?.items ?: emptyList()
} catch (_: Exception) {
emptyList()
}
val items = mutableListOf()
for (section in sections) {
for (item in section.items) {
val mediaItem = browseItemToMediaItem(
when (item) {
is MetadataBrowseItem.Track -> item
is MetadataBrowseItem.Album -> item
is MetadataBrowseItem.Artist -> item
is MetadataBrowseItem.Playlist -> item
is MetadataBrowseItem.User -> null
} ?: continue
) ?: continue
items.add(mediaItem.withGroupTitle(section.title))
}
}
return items
}
private fun MediaItem.withGroupTitle(groupTitle: String): MediaItem {
val existingExtras = mediaMetadata.extras ?: Bundle()
existingExtras.putString(CONTENT_STYLE_GROUP_TITLE, groupTitle)
return buildUpon()
.setMediaMetadata(
mediaMetadata.buildUpon()
.setExtras(existingExtras)
.build()
)
.build()
}
private suspend fun buildLibraryRootChildren(): List {
return listOf(
MediaItem.Builder()
.setMediaId(MEDIA_ID_SAVED_PLAYLISTS)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Playlists")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId(MEDIA_ID_SAVED_ALBUMS)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Albums")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId(MEDIA_ID_SAVED_ARTISTS)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Artists")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId(MEDIA_ID_SAVED_TRACKS)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Saved Tracks")
.setIsPlayable(false)
.setIsBrowsable(true)
.build()
)
.build(),
)
}
private suspend fun buildSavedPlaylistsItems(): List {
val result = try {
libraryRepository.savedPlaylists()?.items ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return result.map { buildPlaylistMediaItem(it) }
}
private suspend fun buildSavedAlbumsItems(): List {
val result = try {
libraryRepository.savedAlbums()?.items ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return result.map { buildAlbumMediaItem(it) }
}
private suspend fun buildSavedArtistsItems(): List {
val result = try {
libraryRepository.savedArtists()?.items ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return result.map { buildArtistMediaItem(it) }
}
private suspend fun buildSavedTracksItems(): List {
val allTracks = mutableListOf()
var pagination = savedTracksRepository.getSavedTracks()
pagination?.items?.let { allTracks.addAll(it) }
while (pagination?.nextPagination != null) {
pagination = savedTracksRepository.getSavedTracks(pagination.nextPagination)
pagination?.items?.let { allTracks.addAll(it) }
}
return allTracks.map { buildTrackMediaItem(it) }
}
private suspend fun buildPlaylistTracksItems(playlistId: String): List {
val allTracks = mutableListOf()
var pagination = playlistRepository.getPlaylistTracks(playlistId)
pagination?.items?.let { allTracks.addAll(it) }
while (pagination?.nextPagination != null) {
pagination = playlistRepository.getPlaylistTracks(playlistId, pagination.nextPagination)
pagination?.items?.let { allTracks.addAll(it) }
}
return allTracks.map { buildTrackMediaItem(it) }
}
private suspend fun buildAlbumTracksItems(albumId: String): List {
val allTracks = mutableListOf()
var pagination = albumRepository.getAlbumTracks(albumId)
pagination?.items?.let { allTracks.addAll(it) }
while (pagination?.nextPagination != null) {
pagination = albumRepository.getAlbumTracks(albumId, pagination.nextPagination)
pagination?.items?.let { allTracks.addAll(it) }
}
return allTracks.map { buildTrackMediaItem(it) }
}
private suspend fun buildArtistOverviewItems(artistId: String): List {
return listOf(
MediaItem.Builder()
.setMediaId("$MEDIA_ID_ARTIST_TRACKS:$artistId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Top Tracks")
.setIsPlayable(false)
.setIsBrowsable(true)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId("$MEDIA_ID_ARTIST_ALBUMS:$artistId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Albums")
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build(),
)
}
private suspend fun buildArtistTracksItems(artistId: String): List {
val plugin = pluginManager.selectedMetadataPlugin.value ?: return emptyList()
val tracks = try {
pluginManager.withScope {
plugin.use { metadataArtistAPI.getArtistTop10Tracks(artistId) }
}
} catch (_: Exception) {
emptyList()
}
val items = mutableListOf()
for (track in tracks) {
val item = buildTrackMediaItem(track)
if (item != null) {
items.add(item)
}
}
return items
}
private suspend fun buildArtistAlbumsItems(artistId: String): List {
val plugin = pluginManager.selectedMetadataPlugin.value ?: return emptyList()
val result = try {
pluginManager.withScope {
plugin.use { metadataArtistAPI.getArtistAlbums(artistId) }
}
} catch (_: Exception) {
null
}
return result?.items?.map { buildAlbumMediaItem(it) } ?: emptyList()
}
private suspend fun buildQueueItems(): List {
val queue = audioPlayerQueue.queueFlow.value
val items = mutableListOf()
for (entry in queue) {
val item = when (entry) {
is QueueEntry.StreamingTrack -> buildTrackMediaItem(entry.track)
is QueueEntry.LocalTrack -> buildLocalTrackMediaItem(entry)
}
if (item != null) {
items.add(item)
}
}
return items
}
private suspend fun playArtistTracks(artistId: String) {
val plugin = pluginManager.selectedMetadataPlugin.value ?: return
val tracks = try {
pluginManager.withScope {
plugin.use { metadataArtistAPI.getArtistTop10Tracks(artistId) }
}
} catch (_: Exception) {
return
}
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
if (entries.isNotEmpty()) {
audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0)
}
}
private suspend fun getTrackById(trackId: String): MetadataTrack? {
val plugin = pluginManager.selectedMetadataPlugin.value ?: return null
return try {
pluginManager.withScope {
plugin.use { metadataTrackAPI.getTrack(trackId) }
}
} catch (_: Exception) {
null
}
}
private fun buildAlbumMediaItem(album: MetadataAlbum): MediaItem {
return MediaItem.Builder()
.setMediaId("$MEDIA_ID_ALBUM:${album.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(album.title)
.setArtist(album.artists.joinToString(", ") { it.name })
.setArtworkUri(album.thumbnails.firstOrNull()?.url?.let { Uri.parse(it) })
.setDescription(album.description)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build()
}
private fun buildPlaylistMediaItem(playlist: MetadataPlaylist): MediaItem {
return MediaItem.Builder()
.setMediaId("$MEDIA_ID_PLAYLIST:${playlist.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(playlist.title)
.setArtist(playlist.owner?.displayName ?: playlist.owner?.username)
.setArtworkUri(playlist.thumbnails.firstOrNull()?.url?.let { Uri.parse(it) })
.setDescription(playlist.description)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build()
}
private fun buildTrackMediaItem(track: MetadataTrack): MediaItem {
return MediaItem.Builder()
.setMediaId("$MEDIA_ID_TRACK:${track.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title)
.setArtist(track.artists.joinToString(", ") { it.name })
.setAlbumTitle(track.album?.title)
.setArtworkUri(track.album?.thumbnails?.firstOrNull()?.url?.let { Uri.parse(it) })
.setTrackNumber(track.trackNumber)
.setIsPlayable(true)
.setIsBrowsable(false)
.build()
)
.build()
}
private fun buildArtistMediaItem(artist: MetadataArtist): MediaItem {
return MediaItem.Builder()
.setMediaId("$MEDIA_ID_ARTIST:${artist.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(artist.name)
.setArtworkUri(artist.thumbnails.firstOrNull()?.url?.let { Uri.parse(it) })
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.setIsBrowsable(true)
.setIsPlayable(false)
.build()
)
.build()
}
private fun buildLocalTrackMediaItem(localTrack: QueueEntry.LocalTrack): MediaItem {
return MediaItem.Builder()
.setMediaId(localTrack.url)
.setUri(localTrack.url)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(localTrack.name)
.setArtist(localTrack.artists.joinToString(", "))
.setAlbumTitle(localTrack.album)
.setIsPlayable(true)
.setIsBrowsable(false)
.build()
)
.build()
}
private suspend fun browseItemToMediaItem(item: MetadataBrowseItem): MediaItem? {
return when (item) {
is MetadataBrowseItem.Track -> buildTrackMediaItem(item.data)
is MetadataBrowseItem.Album -> buildAlbumMediaItem(item.data)
is MetadataBrowseItem.Artist -> buildArtistMediaItem(item.data)
is MetadataBrowseItem.Playlist -> buildPlaylistMediaItem(item.data)
is MetadataBrowseItem.User -> null
}
}
private suspend fun buildStreamingUrl(trackId: String): String? {
val settings = settingsRepository.userSettings.first()
val port = settings.playbackProxyServerPort
if (port == 0) return null
return "http://127.0.0.1:$port/stream/$trackId"
}
}