/* * 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" } }