diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 9c62fdcd2..777cb5ddb 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.os.Bundle import android.os.IBinder import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat @@ -81,31 +82,29 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? { - TODO("Not yet implemented") - } + ): BrowserRoot? = null override fun onLoadChildren( parentId: String, - result: Result> + result: Result> ) = throw NotImplementedError() override fun onLoadChildren( parentId: String, - result: Result>, + result: Result>, options: Bundle ) { super.onLoadChildren(parentId, result, options) } - override fun onLoadItem(itemId: String, result: Result) { + override fun onLoadItem(itemId: String, result: Result) { super.onLoadItem(itemId, result) } override fun onSearch( query: String, extras: Bundle?, - result: Result> + result: Result> ) { super.onSearch(query, extras, result) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt deleted file mode 100644 index 5d578119a..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaItemBrowser.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music.service -// -// import android.content.Context -// import android.os.Bundle -// import androidx.annotation.StringRes -// import androidx.media.utils.MediaConstants -// import androidx.media3.common.MediaItem -// import androidx.media3.session.MediaSession.ControllerInfo -// import dagger.hilt.android.qualifiers.ApplicationContext -// import javax.inject.Inject -// import kotlin.math.min -// import kotlinx.coroutines.CoroutineScope -// import kotlinx.coroutines.Deferred -// import kotlinx.coroutines.Dispatchers -// import kotlinx.coroutines.Job -// import kotlinx.coroutines.async -// import org.oxycblt.auxio.R -// import org.oxycblt.auxio.list.ListSettings -// import org.oxycblt.auxio.list.sort.Sort -// import org.oxycblt.auxio.music.Album -// import org.oxycblt.auxio.music.Artist -// import org.oxycblt.auxio.music.Genre -// import org.oxycblt.auxio.music.Music -// import org.oxycblt.auxio.music.MusicRepository -// import org.oxycblt.auxio.music.Playlist -// import org.oxycblt.auxio.music.Song -// import org.oxycblt.auxio.music.device.DeviceLibrary -// import org.oxycblt.auxio.music.user.UserLibrary -// import org.oxycblt.auxio.search.SearchEngine -// -// class MediaItemBrowser -// @Inject -// constructor( -// @ApplicationContext private val context: Context, -// private val musicRepository: MusicRepository, -// private val listSettings: ListSettings, -// private val searchEngine: SearchEngine -// ) : MusicRepository.UpdateListener { -// private val browserJob = Job() -// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) -// private val searchSubscribers = mutableMapOf() -// private val searchResults = mutableMapOf>() -// private var invalidator: Invalidator? = null -// -// interface Invalidator { -// fun invalidate(ids: Map) -// -// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) -// } -// -// fun attach(invalidator: Invalidator) { -// this.invalidator = invalidator -// musicRepository.addUpdateListener(this) -// } -// -// fun release() { -// browserJob.cancel() -// invalidator = null -// musicRepository.removeUpdateListener(this) -// } -// -// override fun onMusicChanges(changes: MusicRepository.Changes) { -// val deviceLibrary = musicRepository.deviceLibrary -// var invalidateSearch = false -// val invalidate = mutableMapOf() -// if (changes.deviceLibrary && deviceLibrary != null) { -// MediaSessionUID.Category.DEVICE_MUSIC.forEach { -// invalidate[it.toString()] = getCategorySize(it, musicRepository) -// } -// -// deviceLibrary.albums.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size -// } -// -// deviceLibrary.artists.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size -// } -// -// deviceLibrary.genres.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size + it.artists.size -// } -// -// invalidateSearch = true -// } -// val userLibrary = musicRepository.userLibrary -// if (changes.userLibrary && userLibrary != null) { -// MediaSessionUID.Category.USER_MUSIC.forEach { -// invalidate[it.toString()] = getCategorySize(it, musicRepository) -// } -// userLibrary.playlists.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size -// } -// invalidateSearch = true -// } -// -// if (invalidate.isNotEmpty()) { -// invalidator?.invalidate(invalidate) -// } -// -// if (invalidateSearch) { -// for (entry in searchResults.entries) { -// searchResults[entry.key]?.cancel() -// } -// searchResults.clear() -// -// for (entry in searchSubscribers.entries) { -// if (searchResults[entry.value] != null) { -// continue -// } -// searchResults[entry.value] = searchTo(entry.value) -// } -// } -// } -// -// val root: MediaItem -// get() = MediaSessionUID.Category.ROOT.toMediaItem(context) -// -// fun getItem(mediaId: String): MediaItem? { -// val music = -// when (val uid = MediaSessionUID.fromString(mediaId)) { -// is MediaSessionUID.Category -> return uid.toMediaItem(context) -// is MediaSessionUID.Single -> -// musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } -// is MediaSessionUID.Joined -> -// musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } -// null -> null -// } -// ?: return null -// -// return when (music) { -// is Album -> music.toMediaItem(context) -// is Artist -> music.toMediaItem(context) -// is Genre -> music.toMediaItem(context) -// is Playlist -> music.toMediaItem(context) -// is Song -> music.toMediaItem(context, null) -// } -// } -// -// fun getChildren(parentId: String, page: Int, pageSize: Int): List? { -// val deviceLibrary = musicRepository.deviceLibrary -// val userLibrary = musicRepository.userLibrary -// if (deviceLibrary == null || userLibrary == null) { -// return listOf() -// } -// -// val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null -// return items.paginate(page, pageSize) -// } -// -// private fun getMediaItemList( -// id: String, -// deviceLibrary: DeviceLibrary, -// userLibrary: UserLibrary -// ): List? { -// return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { -// is MediaSessionUID.Category -> { -// when (mediaSessionUID) { -// MediaSessionUID.Category.ROOT -> -// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } -// MediaSessionUID.Category.SONGS -> -// listSettings.songSort.songs(deviceLibrary.songs).map { -// it.toMediaItem(context, null) -// } -// MediaSessionUID.Category.ALBUMS -> -// listSettings.albumSort.albums(deviceLibrary.albums).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.ARTISTS -> -// listSettings.artistSort.artists(deviceLibrary.artists).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.GENRES -> -// listSettings.genreSort.genres(deviceLibrary.genres).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.PLAYLISTS -> -// userLibrary.playlists.map { it.toMediaItem(context) } -// } -// } -// is MediaSessionUID.Single -> { -// getChildMediaItems(mediaSessionUID.uid) -// } -// is MediaSessionUID.Joined -> { -// getChildMediaItems(mediaSessionUID.childUid) -// } -// null -> { -// return null -// } -// } -// } -// -// private fun getChildMediaItems(uid: Music.UID): List? { -// return when (val item = musicRepository.find(uid)) { -// is Album -> { -// val songs = listSettings.albumSongSort.songs(item.songs) -// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Artist -> { -// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) -// val songs = listSettings.artistSongSort.songs(item.songs) -// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + -// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Genre -> { -// val artists = GENRE_ARTISTS_SORT.artists(item.artists) -// val songs = listSettings.genreSongSort.songs(item.songs) -// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + -// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } -// } -// is Playlist -> { -// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Song, -// null -> return null -// } -// } -// -// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { -// val oldExtras = mediaMetadata.extras ?: Bundle() -// val newExtras = -// Bundle(oldExtras).apply { -// putString( -// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, -// context.getString(res)) -// } -// return buildUpon() -// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) -// .build() -// } -// -// private fun getCategorySize( -// category: MediaSessionUID.Category, -// musicRepository: MusicRepository -// ): Int { -// val deviceLibrary = musicRepository.deviceLibrary ?: return 0 -// val userLibrary = musicRepository.userLibrary ?: return 0 -// return when (category) { -// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size -// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size -// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size -// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size -// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size -// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size -// } -// } -// -// suspend fun prepareSearch(query: String, controller: ControllerInfo) { -// searchSubscribers[controller] = query -// val existing = searchResults[query] -// if (existing == null) { -// val new = searchTo(query) -// searchResults[query] = new -// new.await() -// } else { -// val items = existing.await() -// invalidator?.invalidate(controller, query, items.count()) -// } -// } -// -// suspend fun getSearchResult( -// query: String, -// page: Int, -// pageSize: Int, -// ): List? { -// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } -// return deferred.await().concat().paginate(page, pageSize) -// } -// -// private fun SearchEngine.Items.concat(): MutableList { -// val music = mutableListOf() -// if (songs != null) { -// music.addAll(songs.map { it.toMediaItem(context, null) }) -// } -// if (albums != null) { -// music.addAll(albums.map { it.toMediaItem(context) }) -// } -// if (artists != null) { -// music.addAll(artists.map { it.toMediaItem(context) }) -// } -// if (genres != null) { -// music.addAll(genres.map { it.toMediaItem(context) }) -// } -// if (playlists != null) { -// music.addAll(playlists.map { it.toMediaItem(context) }) -// } -// return music -// } -// -// private fun SearchEngine.Items.count(): Int { -// var count = 0 -// if (songs != null) { -// count += songs.size -// } -// if (albums != null) { -// count += albums.size -// } -// if (artists != null) { -// count += artists.size -// } -// if (genres != null) { -// count += genres.size -// } -// if (playlists != null) { -// count += playlists.size -// } -// return count -// } -// -// private fun searchTo(query: String) = -// searchScope.async { -// if (query.isEmpty()) { -// return@async SearchEngine.Items() -// } -// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() -// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() -// val items = -// SearchEngine.Items( -// deviceLibrary.songs, -// deviceLibrary.albums, -// deviceLibrary.artists, -// deviceLibrary.genres, -// userLibrary.playlists) -// val results = searchEngine.search(items, query) -// for (entry in searchSubscribers.entries) { -// if (entry.value == query) { -// invalidator?.invalidate(entry.key, query, results.count()) -// } -// } -// results -// } -// -// private fun List.paginate(page: Int, pageSize: Int): List? { -// if (page == Int.MAX_VALUE) { -// // I think if someone requests this page it more or less implies that I should -// // return all of the pages. -// return this -// } -// val start = page * pageSize -// val end = min((page + 1) * pageSize, size) // Tolerate partial page queries -// if (pageSize == 0 || start !in indices) { -// // These pages are probably invalid. Hopefully this won't backfire. -// return null -// } -// return subList(start, end).toMutableList() -// } -// -// private companion object { -// // TODO: Rely on detail item gen logic? -// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) -// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) -// } -// } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 9a5bb53c2..043aa0d46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -22,12 +22,13 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import java.io.ByteArrayOutputStream import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -37,239 +38,37 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural +import java.io.ByteArrayOutputStream +import kotlin.math.ceil -fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { - // TODO: Make custom overflow menu for compat - val style = - Bundle().apply { - putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) - } - val metadata = - MediaMetadata.Builder() - .setTitle(context.getString(nameRes)) - .setIsPlayable(false) - .setIsBrowsable(true) - .setMediaType(mediaType) - .setExtras(style) - if (bitmapRes != null) { - val data = ByteArrayOutputStream() - BitmapFactory.decodeResource(context.resources, bitmapRes) - .compress(Bitmap.CompressFormat.PNG, 100, data) - metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON) - } - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build() -} +enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { + ROOT("root", R.string.info_app_name, null), + MORE("more", R.string.lbl_more, R.drawable.ic_more_24), + SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24), + ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24), + ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24), + GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24), + PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24); -fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.Single(uid) - } else { - MediaSessionUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(album.name.resolve(context)) - .setAlbumArtist(album.artists.resolveNames(context)) - .setTrackNumber(track) - .setDiscNumber(disc?.number) - .setGenre(genres.resolveNames(context)) - .setDisplayTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setRecordingYear(album.dates?.min?.year) - .setRecordingMonth(album.dates?.min?.month) - .setRecordingDay(album.dates?.min?.day) - .setReleaseYear(album.dates?.min?.year) - .setReleaseMonth(album.dates?.min?.month) - .setReleaseDay(album.dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) - .setIsPlayable(true) - .setIsBrowsable(false) - .setArtworkUri(cover.mediaStoreCoverUri) - .setExtras( - Bundle().apply { - putString("uid", mediaSessionUID.toString()) - putLong("durationMs", durationMs) - }) - .build() - return MediaItem.Builder() - .setUri(uri) - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Album.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(name.resolve(context)) - .setAlbumArtist(artists.resolveNames(context)) - .setRecordingYear(dates?.min?.year) - .setRecordingMonth(dates?.min?.month) - .setRecordingDay(dates?.min?.day) - .setReleaseYear(dates?.min?.year) - .setReleaseMonth(dates?.min?.month) - .setReleaseDay(dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Artist.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - context.getString( - R.string.fmt_two, - if (explicitAlbums.isNotEmpty()) { - context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) - } else { - context.getString(R.string.def_album_count) - }, - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - })) - .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(false) - .setIsBrowsable(true) - .setGenre(genres.resolveNames(context)) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Genre.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - }) - .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Playlist.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - }) - .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover?.single?.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { - val uid = MediaSessionUID.fromString(mediaId) ?: return null - return when (uid) { - is MediaSessionUID.Single -> { - deviceLibrary.findSong(uid.uid) - } - is MediaSessionUID.Joined -> { - deviceLibrary.findSong(uid.childUid) - } - is MediaSessionUID.Category -> null + companion object { + val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) + val USER_MUSIC = listOf(ROOT, PLAYLISTS) + val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) } } sealed interface MediaSessionUID { - enum class Category( - val id: String, - @StringRes val nameRes: Int, - @DrawableRes val bitmapRes: Int?, - val mediaType: Int? - ) : MediaSessionUID { - ROOT("root", R.string.info_app_name, null, null), - SONGS( - "songs", - R.string.lbl_songs, - R.drawable.ic_song_bitmap_24, - MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS( - "albums", - R.string.lbl_albums, - R.drawable.ic_album_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS( - "artists", - R.string.lbl_artists, - R.drawable.ic_artist_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES( - "genres", - R.string.lbl_genres, - R.drawable.ic_genre_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS( - "playlists", - R.string.lbl_playlists, - R.drawable.ic_playlist_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); - - override fun toString() = "$ID_CATEGORY:$id" - - companion object { - val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) - val USER_MUSIC = listOf(ROOT, PLAYLISTS) - val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) - } + data class CategoryItem(val category: Category) : MediaSessionUID { + override fun toString() = "$ID_CATEGORY:$category" } - data class Single(val uid: Music.UID) : MediaSessionUID { + data class SingleItem(val uid: Music.UID) : MediaSessionUID { override fun toString() = "$ID_ITEM:$uid" } - data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { + data class ChildItem(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { override fun toString() = "$ID_ITEM:$parentUid>$childUid" } @@ -284,22 +83,23 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> - when (parts[1]) { + CategoryItem(when (parts[1]) { Category.ROOT.id -> Category.ROOT + Category.MORE.id -> Category.MORE Category.SONGS.id -> Category.SONGS Category.ALBUMS.id -> Category.ALBUMS Category.ARTISTS.id -> Category.ARTISTS Category.GENRES.id -> Category.GENRES Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> null - } + else -> return null + }) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { Single(it) } + Music.UID.fromString(uids[0])?.let { SingleItem(it) } } else { Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } + Music.UID.fromString(uids[1])?.let { child -> ChildItem(parent, child) } } } } @@ -308,3 +108,108 @@ sealed interface MediaSessionUID { } } } + +fun Category.toMediaItem(context: Context): MediaItem { + // TODO: Make custom overflow menu for compat + val style = + Bundle().apply { + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) + } + val mediaSessionUID = MediaSessionUID.CategoryItem(this) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(context.getString(nameRes)) + if (bitmapRes != null) { + val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) + description.setIconBitmap(bitmap) + } + return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) +} +fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.SingleItem(uid) + } else { + MediaSessionUID.ChildItem(parent.uid, uid) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(album.cover.single.mediaStoreCoverUri) + .setMediaUri(uri) + .build() + return MediaItem(description, MediaItem.FLAG_PLAYABLE) +} + +fun Album.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Artist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Genre.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Playlist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover?.single?.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt new file mode 100644 index 000000000..62238c5b9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicBrowser.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine +import javax.inject.Inject +import kotlin.math.min + +class MediaItemBrowser +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val listSettings: ListSettings +) : MusicRepository.UpdateListener { + private val browserJob = Job() + private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) + private val searchSubscribers = mutableMapOf() + private val searchResults = mutableMapOf>() + private var invalidator: Invalidator? = null + + interface Invalidator { + fun invalidate(ids: Map) + + fun invalidate(controller: String, query: String, itemCount: Int) + } + + fun attach(invalidator: Invalidator) { + this.invalidator = invalidator + musicRepository.addUpdateListener(this) + } + + fun release() { + browserJob.cancel() + invalidator = null + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary + var invalidateSearch = false + val invalidate = mutableMapOf() + if (changes.deviceLibrary && deviceLibrary != null) { + MediaSessionUID.Category.DEVICE_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + + deviceLibrary.albums.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + } + + deviceLibrary.artists.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size + } + + deviceLibrary.genres.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + it.artists.size + } + + invalidateSearch = true + } + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + MediaSessionUID.Category.USER_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + userLibrary.playlists.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + } + invalidateSearch = true + } + + if (invalidate.isNotEmpty()) { + invalidator?.invalidate(invalidate) + } + + if (invalidateSearch) { + for (entry in searchResults.entries) { + searchResults[entry.key]?.cancel() + } + searchResults.clear() + + for (entry in searchSubscribers.entries) { + if (searchResults[entry.value] != null) { + continue + } + searchResults[entry.value] = searchTo(entry.value) + } + } + } + + val root: MediaItem + get() = MediaSessionUID.Category.ROOT.toMediaItem(context) + + fun getItem(mediaId: String): MediaItem? { + val music = + when (val uid = MediaSessionUID.fromString(mediaId)) { + is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.SingleItem -> + musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } + + is MediaSessionUID.ChildItem -> + musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } + + null -> null + } + ?: return null + + return when (music) { + is Album -> music.toMediaItem(context) + is Artist -> music.toMediaItem(context) + is Genre -> music.toMediaItem(context) + is Playlist -> music.toMediaItem(context) + is Song -> music.toMediaItem(context, null) + } + } + + fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null + return items.paginate(page, pageSize) + } + + private fun getMediaItemList( + id: String, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): List? { + return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { + is MediaSessionUID.Category -> { + when (mediaSessionUID) { + MediaSessionUID.Category.ROOT -> + MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } + + MediaSessionUID.Category.SONGS -> + listSettings.songSort.songs(deviceLibrary.songs).map { + it.toMediaItem(context, null) + } + + MediaSessionUID.Category.ALBUMS -> + listSettings.albumSort.albums(deviceLibrary.albums).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.ARTISTS -> + listSettings.artistSort.artists(deviceLibrary.artists).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.GENRES -> + listSettings.genreSort.genres(deviceLibrary.genres).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.PLAYLISTS -> + userLibrary.playlists.map { it.toMediaItem(context) } + } + } + + is MediaSessionUID.SingleItem -> { + getChildMediaItems(mediaSessionUID.uid) + } + + is MediaSessionUID.ChildItem -> { + getChildMediaItems(mediaSessionUID.childUid) + } + + null -> { + return null + } + } + } + + private fun getChildMediaItems(uid: Music.UID): List? { + return when (val item = musicRepository.find(uid)) { + is Album -> { + val songs = listSettings.albumSongSort.songs(item.songs) + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Artist -> { + val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) + val songs = listSettings.artistSongSort.songs(item.songs) + albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Genre -> { + val artists = GENRE_ARTISTS_SORT.artists(item.artists) + val songs = listSettings.genreSongSort.songs(item.songs) + artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + + songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } + } + + is Playlist -> { + item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Song, + null -> return null + } + } + + private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { + val oldExtras = mediaMetadata.extras ?: Bundle() + val newExtras = + Bundle(oldExtras).apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.getString(res) + ) + } + return buildUpon() + .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) + .build() + } + + private fun getCategorySize( + category: MediaSessionUID.Category, + musicRepository: MusicRepository + ): Int { + val deviceLibrary = musicRepository.deviceLibrary ?: return 0 + val userLibrary = musicRepository.userLibrary ?: return 0 + return when (category) { + MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size + MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size + MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size + MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size + MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size + MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size + } + } + + suspend fun prepareSearch(query: String, controller: ControllerInfo) { + searchSubscribers[controller] = query + val existing = searchResults[query] + if (existing == null) { + val new = searchTo(query) + searchResults[query] = new + new.await() + } else { + val items = existing.await() + invalidator?.invalidate(controller, query, items.count()) + } + } + + suspend fun getSearchResult( + query: String, + page: Int, + pageSize: Int, + ): List? { + val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } + return deferred.await().concat().paginate(page, pageSize) + } + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } + + private fun SearchEngine.Items.count(): Int { + var count = 0 + if (songs != null) { + count += songs.size + } + if (albums != null) { + count += albums.size + } + if (artists != null) { + count += artists.size + } + if (genres != null) { + count += genres.size + } + if (playlists != null) { + count += playlists.size + } + return count + } + + private fun searchTo(query: String) = + searchScope.async { + if (query.isEmpty()) { + return@async SearchEngine.Items() + } + val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() + val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists + ) + val results = searchEngine.search(items, query) + for (entry in searchSubscribers.entries) { + if (entry.value == query) { + invalidator?.invalidate(entry.key, query, results.count()) + } + } + results + } + + private fun List.paginate(page: Int, pageSize: Int): List? { + if (page == Int.MAX_VALUE) { + // I think if someone requests this page it more or less implies that I should + // return all of the pages. + return this + } + val start = page * pageSize + val end = min((page + 1) * pageSize, size) // Tolerate partial page queries + if (pageSize == 0 || start !in indices) { + // These pages are probably invalid. Hopefully this won't backfire. + return null + } + return subList(start, end).toMutableList() + } + + private companion object { + // TODO: Rely on detail item gen logic? + val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 59ac16d95..1abc6bfb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -47,7 +47,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.service.toMediaItem -import org.oxycblt.auxio.music.service.toSong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -110,10 +109,6 @@ class ExoPlaybackStateHolder( override var parent: MusicParent? = null private set - val mediaSessionPlayer: Player - get() = - MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository) - override val progression: Progression get() { val mediaItem = player.currentMediaItem ?: return Progression.nil() @@ -147,7 +142,7 @@ class ExoPlaybackStateHolder( emptyList() } return RawQueue( - heap.mapNotNull { it.toSong(deviceLibrary) }, + heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) } @@ -226,7 +221,7 @@ class ExoPlaybackStateHolder( override fun newPlayback(command: PlaybackCommand) { parent = command.parent player.shuffleModeEnabled = command.shuffled - player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) + player.setMediaItems(command.queue.map { it.buildMediaItem() }) val startIndex = command.song ?.let { command.queue.indexOf(it) } @@ -316,16 +311,16 @@ class ExoPlaybackStateHolder( } if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) } else { - player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() }) } playbackManager.ack(this, ack) deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) playbackManager.ack(this, ack) deferSave() } @@ -382,7 +377,7 @@ class ExoPlaybackStateHolder( sendEvent = true } if (rawQueue != resolveQueue()) { - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) if (rawQueue.isShuffled) { player.shuffleModeEnabled = true player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) @@ -538,6 +533,52 @@ class ExoPlaybackStateHolder( currentSaveJob = saveScope.launch { block() } } + private fun Song.buildMediaItem() = MediaItem.Builder() + .setUri(uri) + .setTag(this) + .build() + + private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song? + + private fun Player.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue + } + class Factory @Inject constructor( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 4c8c367fc..63c281080 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -39,16 +39,25 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.PlaybackActions +import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -64,6 +73,8 @@ private constructor( private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) : @@ -77,12 +88,14 @@ private constructor( constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) { fun create(context: Context) = MediaSessionHolder( - context, playbackManager, playbackSettings, bitmapProvider, imageSettings) + context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings) } private val mediaSession = @@ -201,27 +214,47 @@ private constructor( override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - // STUB: Unimplemented, no media browser + val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return + val command = expandIntoCommand(uid) + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) } override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { super.onPlayFromUri(uri, extras) - // STUB: Unimplemented, no media browser + // STUB } override fun onPlayFromSearch(query: String?, extras: Bundle?) { super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no media browser + // STUB: Unimplemented, no search engine } - override fun onAddQueueItem(description: MediaDescriptionCompat?) { + override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) - // STUB: Unimplemented + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } ?: return + playbackManager.addToQueue(song) } - override fun onRemoveQueueItem(description: MediaDescriptionCompat?) { + override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - // STUB: Unimplemented + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } ?: return + val queueIndex = playbackManager.queue.indexOf(song) + if (queueIndex > -1) { + playbackManager.removeQueueItem(queueIndex) + } } override fun onPlay() { @@ -392,6 +425,40 @@ private constructor( mediaSession.setQueue(queueItems) } + private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.SingleItem -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.ChildItem -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParent(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParent(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ private fun invalidateSessionState() { logD("Updating media session playback state") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt deleted file mode 100644 index f5ea4215c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionPlayer.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.service - -import android.content.Context -import android.os.Bundle -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.ForwardingPlayer -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionParameters -import java.lang.Exception -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.MediaSessionUID -import org.oxycblt.auxio.music.service.toSong -import org.oxycblt.auxio.playback.state.PlaybackCommand -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE - -/** - * A thin wrapper around the player instance that drastically reduces the command surface and - * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that - * Media3 will throw at me will be handled in a predictable way, rather than just clobbering the - * playback state. Largely limited to the legacy media APIs. - * - * I'll add more support as I go along when I can confirm that apps will use the Media3 API and send - * more advanced commands. - * - * @author Alexander Capehart - */ -class MediaSessionPlayer( - private val context: Context, - player: Player, - private val playbackManager: PlaybackStateManager, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository -) : ForwardingPlayer(player) { - override fun getAvailableCommands(): Player.Commands { - return super.getAvailableCommands() - .buildUpon() - .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - } - - override fun isCommandAvailable(command: Int): Boolean { - // We can always skip forward and backward (this is to retain parity with the old behavior) - return super.isCommandAvailable(command) || - command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - } - - override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { - if (!resetPosition) { - error("Playing MediaItems with custom position parameters is not supported") - } - - setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) - } - - override fun getMediaMetadata() = - super.getMediaMetadata().run { - val existingExtras = extras - val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle() - newExtras.apply { - putString( - "parent", - playbackManager.parent?.name?.resolve(context) - ?: context.getString(R.string.lbl_all_songs)) - } - - buildUpon().setExtras(newExtras).build() - } - - override fun setMediaItems( - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ) { - // We assume the only people calling this method are going to be the MediaSession callbacks. - // As part of this, we expand the given MediaItems into the command that should be sent to - // the player. - if (startIndex != C.INDEX_UNSET || startPositionMs != C.TIME_UNSET) { - error("Playing MediaItems with custom position parameters is not supported") - } - if (mediaItems.size != 1) { - error("Playing multiple MediaItems is not supported") - } - val command = expandMediaItemIntoCommand(mediaItems.first()) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) - } - - private fun expandMediaItemIntoCommand(mediaItem: MediaItem): PlaybackCommand? { - val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.Single -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.Joined -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - - return when (music) { - is Song -> inferSongFromParentCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - - override fun play() = playbackManager.playing(true) - - override fun pause() = playbackManager.playing(false) - - override fun setRepeatMode(repeatMode: Int) { - val appRepeatMode = - when (repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } - playbackManager.repeatMode(appRepeatMode) - } - - override fun seekToDefaultPosition(mediaItemIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(mediaItemIndex) - if (fakeIndex < 0) { - return - } - playbackManager.goto(fakeIndex) - } - - override fun seekToNext() = playbackManager.next() - - override fun seekToNextMediaItem() = playbackManager.next() - - override fun seekToPrevious() = playbackManager.prev() - - override fun seekToPreviousMediaItem() = playbackManager.prev() - - override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) - - override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed() - - override fun seekToDefaultPosition() = notAllowed() - - override fun addMediaItems(index: Int, mediaItems: MutableList) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - when { - index == - currentTimeline.getNextWindowIndex( - currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> { - playbackManager.playNext(songs) - } - index >= mediaItemCount -> playbackManager.addToQueue(songs) - else -> error("Unsupported index $index") - } - } - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - playbackManager.shuffled(shuffleModeEnabled) - } - - override fun moveMediaItem(currentIndex: Int, newIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeFrom = indices.indexOf(currentIndex) - if (fakeFrom < 0) { - return - } - val fakeTo = - if (newIndex >= mediaItemCount) { - currentTimeline.getLastWindowIndex(shuffleModeEnabled) - } else { - indices.indexOf(newIndex) - } - if (fakeTo < 0) { - return - } - playbackManager.moveQueueItem(fakeFrom, fakeTo) - } - - override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = - error("Multi-item queue moves are unsupported") - - override fun removeMediaItem(index: Int) { - val indices = unscrambleQueueIndices() - val fakeAt = indices.indexOf(index) - if (fakeAt < 0) { - return - } - playbackManager.removeQueueItem(fakeAt) - } - - override fun removeMediaItems(fromIndex: Int, toIndex: Int) = - error("Any multi-item queue removal is unsupported") - - override fun stop() = playbackManager.endSession() - - // These methods I don't want MediaSession calling in any way since they'll do insane things - // that I'm not tracking. If they do call them, I will know. - - override fun setMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed() - - override fun setMediaItems(mediaItems: MutableList) = notAllowed() - - override fun addMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun addMediaItems(mediaItems: MutableList) = notAllowed() - - override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: MutableList - ) = notAllowed() - - override fun clearMediaItems() = notAllowed() - - override fun setPlaybackSpeed(speed: Float) = notAllowed() - - override fun seekForward() = notAllowed() - - override fun seekBack() = notAllowed() - - @Deprecated("Deprecated in Java") override fun next() = notAllowed() - - @Deprecated("Deprecated in Java") override fun previous() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() - - override fun prepare() = notAllowed() - - override fun release() = notAllowed() - - override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() - - override fun hasNextMediaItem() = notAllowed() - - override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = - notAllowed() - - override fun setVolume(volume: Float) = notAllowed() - - override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed() - - override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed() - - override fun increaseDeviceVolume(flags: Int) = notAllowed() - - override fun decreaseDeviceVolume(flags: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed() - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed() - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed() - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed() - - override fun setVideoSurface(surface: Surface?) = notAllowed() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun setVideoTextureView(textureView: TextureView?) = notAllowed() - - override fun clearVideoSurface() = notAllowed() - - override fun clearVideoSurface(surface: Surface?) = notAllowed() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() - - private fun notAllowed(): Nothing { - logD("MediaSession unexpectedly called this method") - logE(Exception().stackTraceToString()) - error("MediaSession unexpectedly called this method") - } -} - -fun Player.unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 91d6d8b77..26c1eea01 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -109,30 +109,8 @@ constructor( foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - // override fun onConnect( - // session: MediaSession, - // controller: MediaSession.ControllerInfo - // ): ConnectionResult { - // val sessionCommands = - // actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) - // return ConnectionResult.AcceptedResultBuilder(session) - // .setAvailableSessionCommands(sessionCommands) - // .setCustomLayout(actionHandler.createCustomLayout()) - // .build() - // } - // - // override fun onCustomCommand( - // session: MediaSession, - // controller: MediaSession.ControllerInfo, - // customCommand: SessionCommand, - // args: Bundle - // ): ListenableFuture = - // if (actionHandler.handleCommand(customCommand)) { - // Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - // } else { - // super.onCustomCommand(session, controller, customCommand, args) - // } - // + + // override fun onGetLibraryRoot( // session: MediaLibrarySession, // browser: MediaSession.ControllerInfo, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 886e5ce55..8bdd5fa2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,6 +163,7 @@ Reset Add + More Path style Absolute