? {
+ val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null
+ val songs = DetailSection.Songs(playlist.songs)
+ return Detail(playlist, listOf(songs))
+ }
+
+ private companion object {
+ val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
+ val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
+ }
+}
+
+data class Detail(val parent: P, val sections: List)
+
+sealed interface DetailSection {
+ val order: Int
+ val stringRes: Int
+
+ abstract class PlainSection : DetailSection {
+ abstract val items: List
+ }
+
+ data class Artists(override val items: List) : PlainSection() {
+ override val order = 0
+ override val stringRes = R.string.lbl_artists
+ }
+
+ data class Albums(val category: Category, override val items: List) :
+ PlainSection() {
+ override val order = 1 + category.ordinal
+ override val stringRes = category.stringRes
+
+ enum class Category(@StringRes val stringRes: Int) {
+ ALBUMS(R.string.lbl_albums),
+ EPS(R.string.lbl_eps),
+ SINGLES(R.string.lbl_singles),
+ COMPILATIONS(R.string.lbl_compilations),
+ SOUNDTRACKS(R.string.lbl_soundtracks),
+ DJ_MIXES(R.string.lbl_mixes),
+ MIXTAPES(R.string.lbl_mixtapes),
+ DEMOS(R.string.lbl_demos),
+ APPEARANCES(R.string.lbl_appears_on),
+ LIVE(R.string.lbl_live_group),
+ REMIXES(R.string.lbl_remix_group)
+ }
+ }
+
+ data class Songs(override val items: List) : PlainSection() {
+ override val order = 12
+ override val stringRes = R.string.lbl_songs
+ }
+
+ data class Discs(val discs: Map>) : DetailSection {
+ override val order = 13
+ override val stringRes = R.string.lbl_songs
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt
new file mode 100644
index 000000000..2fde9b6a6
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * DetailModule.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.detail
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface DetailModule {
+ @Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index d97b54b09..0fbaf22be 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -18,7 +18,6 @@
package org.oxycblt.auxio.detail
-import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -43,10 +42,11 @@ 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.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
@@ -69,8 +69,9 @@ constructor(
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory,
- private val playbackSettings: PlaybackSettings
-) : ViewModel(), MusicRepository.UpdateListener {
+ private val playbackSettings: PlaybackSettings,
+ detailGeneratorFactory: DetailGenerator.Factory
+) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent()
/**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
@@ -133,13 +134,8 @@ constructor(
get() = _artistSongInstructions
/** The current [Sort] used for [Song]s in [artistSongList]. */
- var artistSongSort: Sort
+ val artistSongSort: Sort
get() = listSettings.artistSongSort
- set(value) {
- listSettings.artistSongSort = value
- // Refresh the artist list to reflect the new sort.
- currentArtist.value?.let { refreshArtistList(it, true) }
- }
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
val playInArtistWith
@@ -162,13 +158,8 @@ constructor(
get() = _genreSongInstructions
/** The current [Sort] used for [Song]s in [genreSongList]. */
- var genreSongSort: Sort
+ val genreSongSort: Sort
get() = listSettings.genreSongSort
- set(value) {
- listSettings.genreSongSort = value
- // Refresh the genre list to reflect the new sort.
- currentGenre.value?.let { refreshGenreList(it, true) }
- }
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
val playInGenreWith
@@ -204,54 +195,35 @@ constructor(
playbackSettings.inParentPlaybackMode
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
+ private val detailGenerator = detailGeneratorFactory.create(this)
+
init {
- musicRepository.addUpdateListener(this)
+ detailGenerator.attach()
}
override fun onCleared() {
- musicRepository.removeUpdateListener(this)
+ detailGenerator.release()
}
- override fun onMusicChanges(changes: MusicRepository.Changes) {
- // If we are showing any item right now, we will need to refresh it (and any information
- // related to it) with the new library in order to prevent stale items from showing up
- // in the UI.
- val deviceLibrary = musicRepository.deviceLibrary
- if (changes.deviceLibrary && deviceLibrary != null) {
- val song = currentSong.value
- if (song != null) {
- _currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
- logD("Updated song to ${currentSong.value}")
+ override fun invalidate(type: MusicType, replace: Int?) {
+ when (type) {
+ MusicType.ALBUMS -> {
+ val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
+ refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
}
-
- val album = currentAlbum.value
- if (album != null) {
- _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
- logD("Updated album to ${currentAlbum.value}")
+ MusicType.ARTISTS -> {
+ val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
+ refreshDetail(
+ artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
}
-
- val artist = currentArtist.value
- if (artist != null) {
- _currentArtist.value =
- deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
- logD("Updated artist to ${currentArtist.value}")
+ MusicType.GENRES -> {
+ val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
+ refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
}
-
- val genre = currentGenre.value
- if (genre != null) {
- _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
- logD("Updated genre to ${currentGenre.value}")
- }
- }
-
- val userLibrary = musicRepository.userLibrary
- if (changes.userLibrary && userLibrary != null) {
- val playlist = currentPlaylist.value
- if (playlist != null) {
- _currentPlaylist.value =
- userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
- logD("Updated playlist to ${currentPlaylist.value}")
+ MusicType.PLAYLISTS -> {
+ refreshPlaylist(currentPlaylist.value?.uid ?: return)
}
+ else -> error("Unexpected music type $type")
}
}
@@ -356,8 +328,11 @@ constructor(
*/
fun setAlbum(uid: Music.UID) {
logD("Opening album $uid")
- _currentAlbum.value =
- musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
+ if (uid === _currentAlbum.value?.uid) {
+ return
+ }
+ val album = detailGenerator.album(uid)
+ refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
if (_currentAlbum.value == null) {
logW("Given album UID was invalid")
}
@@ -370,7 +345,6 @@ constructor(
*/
fun applyAlbumSongSort(sort: Sort) {
listSettings.albumSongSort = sort
- _currentAlbum.value?.let { refreshAlbumList(it, true) }
}
/**
@@ -381,11 +355,11 @@ constructor(
*/
fun setArtist(uid: Music.UID) {
logD("Opening artist $uid")
- _currentArtist.value =
- musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
- if (_currentArtist.value == null) {
- logW("Given artist UID was invalid")
+ if (uid === _currentArtist.value?.uid) {
+ return
}
+ val artist = detailGenerator.artist(uid)
+ refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
}
/**
@@ -395,7 +369,6 @@ constructor(
*/
fun applyArtistSongSort(sort: Sort) {
listSettings.artistSongSort = sort
- _currentArtist.value?.let { refreshArtistList(it, true) }
}
/**
@@ -406,11 +379,11 @@ constructor(
*/
fun setGenre(uid: Music.UID) {
logD("Opening genre $uid")
- _currentGenre.value =
- musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
- if (_currentGenre.value == null) {
- logW("Given genre UID was invalid")
+ if (uid === _currentGenre.value?.uid) {
+ return
}
+ val genre = detailGenerator.genre(uid)
+ refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
}
/**
@@ -420,7 +393,6 @@ constructor(
*/
fun applyGenreSongSort(sort: Sort) {
listSettings.genreSongSort = sort
- _currentGenre.value?.let { refreshGenreList(it, true) }
}
/**
@@ -431,11 +403,10 @@ constructor(
*/
fun setPlaylist(uid: Music.UID) {
logD("Opening playlist $uid")
- _currentPlaylist.value =
- musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
- if (_currentPlaylist.value == null) {
- logW("Given playlist UID was invalid")
+ if (uid === _currentPlaylist.value?.uid) {
+ return
}
+ refreshPlaylist(uid)
}
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
@@ -443,7 +414,7 @@ constructor(
val playlist = _currentPlaylist.value ?: return
logD("Starting playlist edit")
_editedPlaylist.value = playlist.songs
- refreshPlaylistList(playlist)
+ refreshPlaylist(playlist.uid)
}
/**
@@ -474,9 +445,8 @@ constructor(
// Nothing to do.
return false
}
- logD("Discarding playlist edits")
_editedPlaylist.value = null
- refreshPlaylistList(playlist)
+ refreshPlaylist(playlist.uid)
return true
}
@@ -488,7 +458,7 @@ constructor(
fun applyPlaylistSongSort(sort: Sort) {
val playlist = _currentPlaylist.value ?: return
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
- refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
+ refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2))
}
/**
@@ -501,15 +471,15 @@ constructor(
fun movePlaylistSongs(from: Int, to: Int): Boolean {
val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
- val realFrom = from - 2
- val realTo = to - 2
+ val realFrom = from - 1
+ val realTo = to - 1
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false
}
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist
- refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
+ refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
return true
}
@@ -521,20 +491,20 @@ constructor(
fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
- val realAt = at - 2
+ val realAt = at - 1
if (realAt !in editedPlaylist.indices) {
return
}
logD("Removing playlist song at $realAt [$at]")
editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist
- refreshPlaylistList(
- playlist,
+ refreshPlaylist(
+ playlist.uid,
if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1)
} else {
logD("Playlist will be empty after removal, removing header")
- UpdateInstructions.Remove(at - 2, 3)
+ UpdateInstructions.Remove(at - 1, 3)
})
}
@@ -552,173 +522,72 @@ constructor(
}
}
- private fun refreshAlbumList(album: Album, replace: Boolean = false) {
- logD("Refreshing album list")
- val list = mutableListOf- ()
- val header = SortHeader(R.string.lbl_songs)
- list.add(header)
- val instructions =
- if (replace) {
- // Intentional so that the header item isn't replaced with the songs
- UpdateInstructions.Replace(list.size)
- } else {
- UpdateInstructions.Diff
- }
-
- // To create a good user experience regarding disc numbers, we group the album's
- // songs up by disc and then delimit the groups by a disc header.
- val songs = albumSongSort.songs(album.songs)
- val byDisc = songs.groupBy { it.disc }
- if (byDisc.size > 1) {
- logD("Album has more than one disc, interspersing headers")
- for (entry in byDisc.entries) {
- list.add(DiscHeader(entry.key))
- list.addAll(entry.value)
- }
- } else {
- // Album only has one disc, don't add any redundant headers
- list.addAll(songs)
+ private fun refreshDetail(
+ detail: Detail?,
+ parent: MutableStateFlow,
+ list: MutableStateFlow
>,
+ instructions: MutableEvent,
+ replace: Int?
+ ) {
+ if (detail == null) {
+ parent.value = null
+ return
}
-
- logD("Update album list to ${list.size} items with $instructions")
- _albumSongInstructions.put(instructions)
- _albumSongList.value = list
- }
-
- private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
- logD("Refreshing artist list")
- val list = mutableListOf- ()
-
- val grouping =
- artist.explicitAlbums.groupByTo(sortedMapOf()) {
- // Remap the complicated ReleaseType data structure into an easier
- // "AlbumGrouping" enum that will automatically group and sort
- // the artist's albums.
- when (it.releaseType.refinement) {
- ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
- ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
- null ->
- when (it.releaseType) {
- is ReleaseType.Album -> AlbumGrouping.ALBUMS
- is ReleaseType.EP -> AlbumGrouping.EPS
- is ReleaseType.Single -> AlbumGrouping.SINGLES
- is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
- is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
- is ReleaseType.Mix -> AlbumGrouping.DJMIXES
- is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
- is ReleaseType.Demo -> AlbumGrouping.DEMOS
- }
+ val newList = mutableListOf
- ()
+ var newInstructions: UpdateInstructions = UpdateInstructions.Diff
+ for ((i, section) in detail.sections.withIndex()) {
+ val items =
+ when (section) {
+ is DetailSection.PlainSection<*> -> {
+ val header =
+ if (section is DetailSection.Songs) SortHeader(section.stringRes)
+ else BasicHeader(section.stringRes)
+ newList.add(Divider(header))
+ newList.add(header)
+ section.items
+ }
+ is DetailSection.Discs -> {
+ val header = SortHeader(section.stringRes)
+ newList.add(Divider(header))
+ newList.add(header)
+ section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
+ }
}
- }
-
- if (artist.implicitAlbums.isNotEmpty()) {
- // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
- // inherits list, we can cast upwards and save a copy by directly inserting the
- // implicit album list into the mapping.
- logD("Implicit albums present, adding to list")
- @Suppress("UNCHECKED_CAST")
- (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] =
- artist.implicitAlbums
- }
-
- logD("Release groups for this artist: ${grouping.keys}")
-
- for ((i, entry) in grouping.entries.withIndex()) {
- val header = BasicHeader(entry.key.headerTitleRes)
- if (i > 0) {
- list.add(Divider(header))
- }
- list.add(header)
- list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
- }
-
- // Artists may not be linked to any songs, only include a header entry if we have any.
- var instructions: UpdateInstructions = UpdateInstructions.Diff
- if (artist.songs.isNotEmpty()) {
- logD("Songs present in this artist, adding header")
- val header = SortHeader(R.string.lbl_songs)
- list.add(Divider(header))
- list.add(header)
- if (replace) {
+ // Currently only the final section (songs, which can be sorted) are invalidatable
+ // and thus need to be replaced.
+ if (replace == -1 && i == detail.sections.lastIndex) {
// Intentional so that the header item isn't replaced with the songs
- instructions = UpdateInstructions.Replace(list.size)
+ newInstructions = UpdateInstructions.Replace(newList.size)
}
- list.addAll(artistSongSort.songs(artist.songs))
+ newList.addAll(items)
}
-
- logD("Updating artist list to ${list.size} items with $instructions")
- _artistSongInstructions.put(instructions)
- _artistSongList.value = list.toList()
+ parent.value = detail.parent
+ instructions.put(newInstructions)
+ list.value = newList
}
- private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
- logD("Refreshing genre list")
- val list = mutableListOf
- ()
- // Genre is guaranteed to always have artists and songs.
- val artistHeader = BasicHeader(R.string.lbl_artists)
- list.add(artistHeader)
- list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
-
- val songHeader = SortHeader(R.string.lbl_songs)
- list.add(Divider(songHeader))
- list.add(songHeader)
- val instructions =
- if (replace) {
- // Intentional so that the header item isn't replaced alongside the songs
- UpdateInstructions.Replace(list.size)
- } else {
- UpdateInstructions.Diff
- }
- list.addAll(genreSongSort.songs(genre.songs))
-
- logD("Updating genre list to ${list.size} items with $instructions")
- _genreSongInstructions.put(instructions)
- _genreSongList.value = list
- }
-
- private fun refreshPlaylistList(
- playlist: Playlist,
+ private fun refreshPlaylist(
+ uid: Music.UID,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
logD("Refreshing playlist list")
- val list = mutableListOf
- ()
-
- val songs = editedPlaylist.value ?: playlist.songs
- if (songs.isNotEmpty()) {
- val header = EditHeader(R.string.lbl_songs)
- list.add(header)
- list.addAll(songs)
+ val edited = editedPlaylist.value
+ if (edited == null) {
+ val playlist = detailGenerator.playlist(uid)
+ refreshDetail(
+ playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null)
+ return
+ }
+ val list = mutableListOf
- ()
+ if (edited.isNotEmpty()) {
+ val header = EditHeader(R.string.lbl_songs)
+ list.add(Divider(header))
+ list.add(header)
+ list.addAll(edited)
}
-
- logD("Updating playlist list to ${list.size} items with $instructions")
_playlistSongInstructions.put(instructions)
_playlistSongList.value = list
}
-
- /**
- * A simpler mapping of [ReleaseType] used for grouping and sorting songs.
- *
- * @param headerTitleRes The title string resource to use for a header created out of an
- * instance of this enum.
- */
- private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
- ALBUMS(R.string.lbl_albums),
- EPS(R.string.lbl_eps),
- SINGLES(R.string.lbl_singles),
- COMPILATIONS(R.string.lbl_compilations),
- SOUNDTRACKS(R.string.lbl_soundtracks),
- DJMIXES(R.string.lbl_mixes),
- MIXTAPES(R.string.lbl_mixtapes),
- DEMOS(R.string.lbl_demos),
- APPEARANCES(R.string.lbl_appears_on),
- LIVE(R.string.lbl_live_group),
- REMIXES(R.string.lbl_remix_group),
- }
-
- private companion object {
- val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
- val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
- }
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt
index 63419e1e5..c1134b207 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt
@@ -35,10 +35,10 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
+import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
-import org.oxycblt.auxio.util.logD
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@@ -111,16 +111,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
*/
fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner
- if (disc != null) {
- binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
- binding.discName.apply {
- text = disc.name
- isGone = disc.name == null
- }
- } else {
- logD("Disc is null, defaulting to no disc")
- binding.discNumber.text = binding.context.getString(R.string.def_disc)
- binding.discName.isGone = true
+ binding.discNumber.text = disc.resolveNumber(binding.context)
+ binding.discName.apply {
+ text = disc?.name
+ isGone = disc?.name == null
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt
new file mode 100644
index 000000000..52135d3d4
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * HomeGenerator.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.home
+
+import javax.inject.Inject
+import org.oxycblt.auxio.home.tabs.Tab
+import org.oxycblt.auxio.list.ListSettings
+import org.oxycblt.auxio.list.adapter.UpdateInstructions
+import org.oxycblt.auxio.music.Album
+import org.oxycblt.auxio.music.Artist
+import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.MusicRepository
+import org.oxycblt.auxio.music.MusicType
+import org.oxycblt.auxio.music.Playlist
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.util.logD
+
+interface HomeGenerator {
+ fun attach()
+
+ fun release()
+
+ fun songs(): List
+
+ fun albums(): List
+
+ fun artists(): List
+
+ fun genres(): List
+
+ fun playlists(): List
+
+ fun tabs(): List
+
+ interface Invalidator {
+ fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
+
+ fun invalidateTabs()
+ }
+
+ interface Factory {
+ fun create(invalidator: Invalidator): HomeGenerator
+ }
+}
+
+class HomeGeneratorFactoryImpl
+@Inject
+constructor(
+ private val homeSettings: HomeSettings,
+ private val listSettings: ListSettings,
+ private val musicRepository: MusicRepository,
+) : HomeGenerator.Factory {
+ override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator =
+ HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository)
+}
+
+private class HomeGeneratorImpl(
+ private val invalidator: HomeGenerator.Invalidator,
+ private val homeSettings: HomeSettings,
+ private val listSettings: ListSettings,
+ private val musicRepository: MusicRepository,
+) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener {
+ override fun attach() {
+ homeSettings.registerListener(this)
+ listSettings.registerListener(this)
+ musicRepository.addUpdateListener(this)
+ }
+
+ override fun onTabsChanged() {
+ invalidator.invalidateTabs()
+ }
+
+ override fun onHideCollaboratorsChanged() {
+ // Changes in the hide collaborator setting will change the artist contents
+ // of the library, consider it a library update.
+ logD("Collaborator setting changed, forwarding update")
+ onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
+ }
+
+ override fun onSongSortChanged() {
+ super.onSongSortChanged()
+ invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0))
+ }
+
+ override fun onAlbumSortChanged() {
+ super.onAlbumSortChanged()
+ invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0))
+ }
+
+ override fun onArtistSortChanged() {
+ super.onArtistSortChanged()
+ invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0))
+ }
+
+ override fun onGenreSortChanged() {
+ super.onGenreSortChanged()
+ invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0))
+ }
+
+ override fun onPlaylistSortChanged() {
+ super.onPlaylistSortChanged()
+ invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0))
+ }
+
+ override fun onMusicChanges(changes: MusicRepository.Changes) {
+ val deviceLibrary = musicRepository.deviceLibrary
+ if (changes.deviceLibrary && deviceLibrary != null) {
+ logD("Refreshing library")
+ // Get the each list of items in the library to use as our list data.
+ // Applying the preferred sorting to them.
+ invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff)
+ invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff)
+ invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
+ invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
+ }
+
+ val userLibrary = musicRepository.userLibrary
+ if (changes.userLibrary && userLibrary != null) {
+ logD("Refreshing playlists")
+ invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
+ }
+ }
+
+ override fun release() {
+ musicRepository.removeUpdateListener(this)
+ listSettings.unregisterListener(this)
+ homeSettings.unregisterListener(this)
+ }
+
+ override fun songs() =
+ musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
+
+ override fun albums() =
+ musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
+ ?: emptyList()
+
+ override fun artists() =
+ musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) }
+ ?: emptyList()
+
+ override fun genres() =
+ musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
+ ?: emptyList()
+
+ override fun playlists() =
+ musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
+ ?: emptyList()
+
+ override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt
index a578b6e07..e7e2f9118 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt
@@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class)
interface HomeModule {
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
+
+ @Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
index 5fc218cfe..ec54942f3 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
@@ -42,9 +42,9 @@ interface HomeSettings : Settings {
interface Listener {
/** Called when the [homeTabs] configuration changes. */
- fun onTabsChanged()
+ fun onTabsChanged() {}
/** Called when the [shouldHideCollaborators] configuration changes. */
- fun onHideCollaboratorsChanged()
+ fun onHideCollaboratorsChanged() {}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
index bb9311c84..2dcf5b2a4 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
@@ -30,7 +30,6 @@ 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.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
@@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD
class HomeViewModel
@Inject
constructor(
- private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val playbackSettings: PlaybackSettings,
- private val musicRepository: MusicRepository,
-) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
-
+ homeGeneratorFactory: HomeGenerator.Factory
+) : ViewModel(), HomeGenerator.Invalidator {
private val _songList = MutableStateFlow(listOf())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songList: StateFlow
>
@@ -132,11 +129,13 @@ constructor(
val playlistSort: Sort
get() = listSettings.playlistSort
+ private val homeGenerator = homeGeneratorFactory.create(this)
+
/**
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s.
*/
- var currentTabTypes = makeTabTypes()
+ var currentTabTypes = homeGenerator.tabs()
private set
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
@@ -161,63 +160,44 @@ constructor(
get() = _showOuter
init {
- musicRepository.addUpdateListener(this)
- homeSettings.registerListener(this)
+ homeGenerator.attach()
}
override fun onCleared() {
super.onCleared()
- musicRepository.removeUpdateListener(this)
- homeSettings.unregisterListener(this)
+ homeGenerator.release()
}
- override fun onMusicChanges(changes: MusicRepository.Changes) {
- val deviceLibrary = musicRepository.deviceLibrary
- if (changes.deviceLibrary && deviceLibrary != null) {
- logD("Refreshing library")
- // Get the each list of items in the library to use as our list data.
- // Applying the preferred sorting to them.
- _songInstructions.put(UpdateInstructions.Diff)
- _songList.value = listSettings.songSort.songs(deviceLibrary.songs)
- _albumInstructions.put(UpdateInstructions.Diff)
- _albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
- _artistInstructions.put(UpdateInstructions.Diff)
- _artistList.value =
- listSettings.artistSort.artists(
- if (homeSettings.shouldHideCollaborators) {
- logD("Filtering collaborator artists")
- // Hide Collaborators is enabled, filter out collaborators.
- deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
- } else {
- logD("Using all artists")
- deviceLibrary.artists
- })
- _genreInstructions.put(UpdateInstructions.Diff)
- _genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
- }
-
- val userLibrary = musicRepository.userLibrary
- if (changes.userLibrary && userLibrary != null) {
- logD("Refreshing playlists")
- _playlistInstructions.put(UpdateInstructions.Diff)
- _playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
+ override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
+ when (type) {
+ MusicType.SONGS -> {
+ _songInstructions.put(instructions)
+ _songList.value = homeGenerator.songs()
+ }
+ MusicType.ALBUMS -> {
+ _albumInstructions.put(instructions)
+ _albumList.value = homeGenerator.albums()
+ }
+ MusicType.ARTISTS -> {
+ _artistInstructions.put(instructions)
+ _artistList.value = homeGenerator.artists()
+ }
+ MusicType.GENRES -> {
+ _genreInstructions.put(instructions)
+ _genreList.value = homeGenerator.genres()
+ }
+ MusicType.PLAYLISTS -> {
+ _playlistInstructions.put(instructions)
+ _playlistList.value = homeGenerator.playlists()
+ }
}
}
- override fun onTabsChanged() {
- // Tabs changed, update the current tabs and set up a re-create event.
- currentTabTypes = makeTabTypes()
- logD("Updating tabs: ${currentTabType.value}")
+ override fun invalidateTabs() {
+ currentTabTypes = homeGenerator.tabs()
_shouldRecreate.put(Unit)
}
- override fun onHideCollaboratorsChanged() {
- // Changes in the hide collaborator setting will change the artist contents
- // of the library, consider it a library update.
- logD("Collaborator setting changed, forwarding update")
- onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
- }
-
/**
* Apply a new [Sort] to [songList].
*
@@ -225,8 +205,6 @@ constructor(
*/
fun applySongSort(sort: Sort) {
listSettings.songSort = sort
- _songInstructions.put(UpdateInstructions.Replace(0))
- _songList.value = listSettings.songSort.songs(_songList.value)
}
/**
@@ -236,8 +214,6 @@ constructor(
*/
fun applyAlbumSort(sort: Sort) {
listSettings.albumSort = sort
- _albumInstructions.put(UpdateInstructions.Replace(0))
- _albumList.value = listSettings.albumSort.albums(_albumList.value)
}
/**
@@ -247,8 +223,6 @@ constructor(
*/
fun applyArtistSort(sort: Sort) {
listSettings.artistSort = sort
- _artistInstructions.put(UpdateInstructions.Replace(0))
- _artistList.value = listSettings.artistSort.artists(_artistList.value)
}
/**
@@ -258,8 +232,6 @@ constructor(
*/
fun applyGenreSort(sort: Sort) {
listSettings.genreSort = sort
- _genreInstructions.put(UpdateInstructions.Replace(0))
- _genreList.value = listSettings.genreSort.genres(_genreList.value)
}
/**
@@ -269,8 +241,6 @@ constructor(
*/
fun applyPlaylistSort(sort: Sort) {
listSettings.playlistSort = sort
- _playlistInstructions.put(UpdateInstructions.Replace(0))
- _playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
}
/**
@@ -300,15 +270,6 @@ constructor(
fun showAbout() {
_showOuter.put(Outer.About)
}
-
- /**
- * Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
- *
- * @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
- * the same way as the configuration.
- */
- private fun makeTabTypes() =
- homeSettings.homeTabs.filterIsInstance().map { it.type }
}
sealed interface Outer {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt
index 73170ef4c..7cf0c2a53 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt
@@ -37,40 +37,24 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) :
private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
- val icon: Int
- val string: Int
-
- when (tabs[position]) {
- MusicType.SONGS -> {
- icon = R.drawable.ic_song_24
- string = R.string.lbl_songs
+ val homeTab = tabs[position]
+ val icon =
+ when (homeTab) {
+ MusicType.SONGS -> R.drawable.ic_song_24
+ MusicType.ALBUMS -> R.drawable.ic_album_24
+ MusicType.ARTISTS -> R.drawable.ic_artist_24
+ MusicType.GENRES -> R.drawable.ic_genre_24
+ MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
}
- MusicType.ALBUMS -> {
- icon = R.drawable.ic_album_24
- string = R.string.lbl_albums
- }
- MusicType.ARTISTS -> {
- icon = R.drawable.ic_artist_24
- string = R.string.lbl_artists
- }
- MusicType.GENRES -> {
- icon = R.drawable.ic_genre_24
- string = R.string.lbl_genres
- }
- MusicType.PLAYLISTS -> {
- icon = R.drawable.ic_playlist_24
- string = R.string.lbl_playlists
- }
- }
// Use expected sw* size thresholds when choosing a configuration.
when {
// On small screens, only display an icon.
- width < 370 -> tab.setIcon(icon).setContentDescription(string)
+ width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
// On large screens, display an icon and text.
- width < 600 -> tab.setText(string)
+ width < 600 -> tab.setText(homeTab.nameRes)
// On medium-size screens, display text.
- else -> tab.setIcon(icon).setText(string)
+ else -> tab.setIcon(icon).setText(homeTab.nameRes)
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt
index c8d3ee145..0d32d20de 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt
@@ -107,7 +107,10 @@ class RoundedRectTransformation(
}
private fun calculateOutputSize(input: Bitmap, size: Size): Pair {
- // MODIFICATION: Remove short-circuiting for original size and input size
+ if (size == Size.ORIGINAL) {
+ // This path only runs w/the widget code, which already normalizes widget sizes
+ return input.width to input.height
+ }
val multiplier =
DecodeUtils.computeSizeMultiplier(
srcWidth = input.width,
diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt
index 3f3388b73..7dcbfa13f 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt
@@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.settings.Settings
-interface ListSettings : Settings {
+interface ListSettings : Settings {
/** The [Sort] mode used in Song lists. */
var songSort: Sort
/** The [Sort] mode used in Album lists. */
@@ -43,10 +43,28 @@ interface ListSettings : Settings {
var artistSongSort: Sort
/** The [Sort] mode used in a Genre's Song list. */
var genreSongSort: Sort
+
+ interface Listener {
+ fun onSongSortChanged() {}
+
+ fun onAlbumSortChanged() {}
+
+ fun onAlbumSongSortChanged() {}
+
+ fun onArtistSortChanged() {}
+
+ fun onArtistSongSortChanged() {}
+
+ fun onGenreSortChanged() {}
+
+ fun onGenreSongSortChanged() {}
+
+ fun onPlaylistSortChanged() {}
+ }
}
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
- Settings.Impl(context), ListSettings {
+ Settings.Impl(context), ListSettings {
override var songSort: Sort
get() =
Sort.fromIntCode(
@@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
apply()
}
}
+
+ override fun onSettingChanged(key: String, listener: ListSettings.Listener) {
+ when (key) {
+ getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged()
+ getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged()
+ getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged()
+ getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged()
+ getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged()
+ getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged()
+ getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged()
+ getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged()
+ }
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt
index 19f535af1..572280f8e 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt
@@ -19,6 +19,7 @@
package org.oxycblt.auxio.music
import org.oxycblt.auxio.IntegerTable
+import org.oxycblt.auxio.R
/**
* General configuration enum to control what kind of music is being worked with.
@@ -52,6 +53,16 @@ enum class MusicType {
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
}
+ val nameRes: Int
+ get() =
+ when (this) {
+ SONGS -> R.string.lbl_songs
+ ALBUMS -> R.string.lbl_albums
+ ARTISTS -> R.string.lbl_artists
+ GENRES -> R.string.lbl_genres
+ PLAYLISTS -> R.string.lbl_playlists
+ }
+
companion object {
/**
* Convert a [MusicType] integer representation into an instance.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
index 2a7113066..5ea7c8ebf 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
@@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped
-@Database(entities = [CachedSong::class], version = 46, exportSchema = false)
+@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt
index 2c8fd360b..5f2b52bd6 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt
@@ -18,6 +18,8 @@
package org.oxycblt.auxio.music.info
+import android.content.Context
+import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
/**
@@ -34,3 +36,7 @@ class Disc(val number: Int, val name: String?) : Item, Comparable {
override fun compareTo(other: Disc) = number.compareTo(other.number)
}
+
+fun Disc?.resolveNumber(context: Context) =
+ this?.run { context.getString(R.string.fmt_disc_no, number) }
+ ?: context.getString(R.string.def_disc)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
index 30626f01e..30f4564c8 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
@@ -70,12 +70,12 @@ sealed interface Name : Comparable {
final override fun compareTo(other: Name) =
when (other) {
is Known -> {
- // Progressively compare the sort tokens between each known name.
- sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
- acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
- }
+ val result =
+ sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
+ acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
+ }
+ if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size)
}
- // Unknown names always come before known names.
is Unknown -> 1
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt
index 3f70981ba..f5c16f985 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt
@@ -100,6 +100,7 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map>) {
// Song
+ logD(textFrames)
(textFrames["TXXX:musicbrainz release track id"]
?: textFrames["TXXX:musicbrainz_releasetrackid"])
?.let { rawSong.musicBrainzId = it.first() }
@@ -147,10 +148,13 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
rawSong.artistMusicBrainzIds = it
}
- (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
+ (textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
+ rawSong.artistNames = it
+ }
(textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
- ?: textFrames["TSOP"])
+ ?: textFrames["TSOP"] ?: textFrames["artistsort"]
+ ?: textFrames["TXXX:artist sort"])
?.let { rawSong.artistSortNames = it }
// Album artist
@@ -159,13 +163,14 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
?.let { rawSong.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
- ?: textFrames["TPE2"])
+ ?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"]
+ ?: textFrames["TXXX:album artist"])
?.let { rawSong.albumArtistNames = it }
(textFrames["TXXX:albumartistssort"]
?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"]
?: textFrames["TXXX:albumartistsort"]
// This is a non-standard iTunes extension
- ?: textFrames["TSO2"])
+ ?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"])
?.let { rawSong.albumArtistSortNames = it }
// Genre
@@ -273,7 +278,8 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
}
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artistssort"]
- ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
+ ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]
+ ?: comments["artist sort"])
?.let { rawSong.artistSortNames = it }
// Album artist
@@ -281,12 +287,12 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
rawSong.albumArtistMusicBrainzIds = it
}
(comments["albumartists"]
- ?: comments["album_artists"] ?: comments["album artists"]
- ?: comments["albumartist"])
+ ?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"]
+ ?: comments["album artist"])
?.let { rawSong.albumArtistNames = it }
(comments["albumartistssort"]
?: comments["albumartists_sort"] ?: comments["albumartists sort"]
- ?: comments["albumartistsort"])
+ ?: comments["albumartistsort"] ?: comments["album artist sort"])
?.let { rawSong.albumArtistSortNames = it }
// Genre
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt
similarity index 84%
rename from app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt
rename to app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt
index c6277ba7c..5552aec69 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt
@@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
- * IndexerServiceFragment.kt is part of Auxio.
+ * Indexer.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
@@ -21,13 +21,13 @@ package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.PowerManager
import coil.ImageLoader
-import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener
+import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
@@ -35,34 +35,52 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
-class IndexerServiceFragment
-@Inject
-constructor(
- @ApplicationContext override val workerContext: Context,
+class Indexer
+private constructor(
+ override val workerContext: Context,
+ private val foregroundListener: ForegroundListener,
private val playbackManager: PlaybackStateManager,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings,
- private val contentObserver: SystemContentObserver,
- private val imageLoader: ImageLoader
+ private val imageLoader: ImageLoader,
+ private val contentObserver: SystemContentObserver
) :
MusicRepository.IndexingWorker,
MusicRepository.IndexingListener,
MusicRepository.UpdateListener,
MusicSettings.Listener {
+ class Factory
+ @Inject
+ constructor(
+ private val playbackManager: PlaybackStateManager,
+ private val musicRepository: MusicRepository,
+ private val musicSettings: MusicSettings,
+ private val imageLoader: ImageLoader,
+ private val contentObserver: SystemContentObserver
+ ) {
+ fun create(context: Context, listener: ForegroundListener) =
+ Indexer(
+ context,
+ listener,
+ playbackManager,
+ musicRepository,
+ musicSettings,
+ imageLoader,
+ contentObserver)
+ }
+
private val indexJob = Job()
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
private val indexingNotification = IndexingNotification(workerContext)
private val observingNotification = ObservingNotification(workerContext)
- private var foregroundListener: ForegroundListener? = null
private val wakeLock =
workerContext
.getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
- fun attach(listener: ForegroundListener) {
- foregroundListener = listener
+ fun attach() {
musicSettings.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
@@ -76,7 +94,6 @@ constructor(
musicRepository.removeIndexingListener(this)
musicRepository.removeUpdateListener(this)
musicSettings.unregisterListener(this)
- foregroundListener = null
}
fun start() {
@@ -85,7 +102,7 @@ constructor(
}
}
- fun createNotification(post: (IndexerNotification?) -> Unit) {
+ fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
// There are a few reasons why we stay in the foreground with automatic rescanning:
@@ -118,7 +135,7 @@ constructor(
override val scope = indexScope
override fun onIndexingStateChanged() {
- foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
+ foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
val state = musicRepository.indexingState
if (state is IndexingState.Indexing) {
wakeLock.acquireSafe()
@@ -157,9 +174,9 @@ constructor(
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
- if (currentIndexJob == null) {
+ if (musicRepository.indexingState == null) {
logD("Not loading, updating idle session")
- foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
+ foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
index d857ab32b..0e895196d 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
@@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service
import android.content.Context
import android.os.SystemClock
-import androidx.annotation.StringRes
-import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
import org.oxycblt.auxio.BuildConfig
+import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.IndexingProgress
@@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
/**
- * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
- * signal a Service's ongoing foreground state.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-abstract class IndexerNotification(context: Context, info: ChannelInfo) :
- NotificationCompat.Builder(context, info.id) {
- private val notificationManager = NotificationManagerCompat.from(context)
-
- init {
- // Set up the notification channel. Foreground notifications are non-substantial, and
- // thus make no sense to have lights, vibration, or lead to a notification badge.
- val channel =
- NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
- .setName(context.getString(info.nameRes))
- .setLightsEnabled(false)
- .setVibrationEnabled(false)
- .setShowBadge(false)
- .build()
- notificationManager.createNotificationChannel(channel)
- }
-
- /**
- * The code used to identify this notification.
- *
- * @see NotificationManagerCompat.notify
- */
- abstract val code: Int
-
- /**
- * Reduced representation of a [NotificationChannelCompat].
- *
- * @param id The ID of the channel.
- * @param nameRes A string resource ID corresponding to the human-readable name of this channel.
- */
- data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
-}
-
-/**
- * A dynamic [IndexerNotification] that shows the current music loading state.
+ * A dynamic [ForegroundServiceNotification] that shows the current music loading state.
*
* @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexingNotification(private val context: Context) :
- IndexerNotification(context, indexerChannel) {
+ ForegroundServiceNotification(context, indexerChannel) {
private var lastUpdateTime = -1L
init {
@@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) :
}
/**
- * A static [IndexerNotification] that signals to the user that the app is currently monitoring the
- * music library for changes.
+ * A static [ForegroundServiceNotification] that signals to the user that the app is currently
+ * monitoring the music library for changes.
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
+class ObservingNotification(context: Context) :
+ ForegroundServiceNotification(context, indexerChannel) {
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
@@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel =
- IndexerNotification.ChannelInfo(
+ ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
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 93841a63f..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..48488a376 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
@@ -19,15 +19,12 @@
package org.oxycblt.auxio.music.service
import android.content.Context
-import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
-import androidx.annotation.DrawableRes
+import android.support.v4.media.MediaBrowserCompat.MediaItem
+import android.support.v4.media.MediaDescriptionCompat
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,242 +34,19 @@ 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.playback.formatDurationDs
import org.oxycblt.auxio.util.getPlural
-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()
-}
-
-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
- }
-}
-
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 Tab(val node: TabNode) : MediaSessionUID {
+ override fun toString() = "$ID_CATEGORY:${node.id}"
}
- 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 {
- override fun toString() = "$ID_ITEM:$parentUid>$childUid"
- }
-
companion object {
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
@@ -283,28 +57,154 @@ sealed interface MediaSessionUID {
return null
}
return when (parts[0]) {
- ID_CATEGORY ->
- when (parts[1]) {
- Category.ROOT.id -> Category.ROOT
- 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
- }
- ID_ITEM -> {
- val uids = parts[1].split(">", limit = 2)
- if (uids.size == 1) {
- Music.UID.fromString(uids[0])?.let { Single(it) }
- } else {
- Music.UID.fromString(uids[0])?.let { parent ->
- Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
- }
- }
- }
+ ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null)
+ ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null)
else -> return null
}
}
}
}
+
+typealias Sugar = Bundle.(Context) -> Unit
+
+fun header(@StringRes nameRes: Int): Sugar = {
+ putString(
+ MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes))
+}
+
+fun header(name: String): Sugar = {
+ putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name)
+}
+
+fun child(of: MusicParent): Sugar = {
+ putString(MusicBrowser.KEY_CHILD_OF, MediaSessionUID.SingleItem(of.uid).toString())
+}
+
+private fun style(style: Int): Sugar = {
+ putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style)
+}
+
+private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle {
+ return Bundle().apply { sugars.forEach { this.it(context) } }
+}
+
+fun TabNode.toMediaItem(context: Context): MediaItem {
+ val extras =
+ makeExtras(
+ context,
+ style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM))
+ val mediaSessionUID = MediaSessionUID.Tab(this)
+ val description =
+ MediaDescriptionCompat.Builder()
+ .setMediaId(mediaSessionUID.toString())
+ .setTitle(context.getString(nameRes))
+ .setExtras(extras)
+ bitmapRes?.let { res ->
+ val bitmap = BitmapFactory.decodeResource(context.resources, res)
+ description.setIconBitmap(bitmap)
+ }
+ return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE)
+}
+
+fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescriptionCompat {
+ val mediaSessionUID = MediaSessionUID.SingleItem(uid)
+ val extras = makeExtras(context, *sugar)
+ return MediaDescriptionCompat.Builder()
+ .setMediaId(mediaSessionUID.toString())
+ .setTitle(name.resolve(context))
+ .setSubtitle(artists.resolveNames(context))
+ .setDescription(album.name.resolve(context))
+ .setIconUri(cover.mediaStoreCoverUri)
+ .setMediaUri(uri)
+ .setExtras(extras)
+ .build()
+}
+
+fun Song.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
+ return MediaItem(toMediaDescription(context, *sugar), MediaItem.FLAG_PLAYABLE)
+}
+
+fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
+ val mediaSessionUID = MediaSessionUID.SingleItem(uid)
+ val extras = makeExtras(context, *sugar)
+ val counts = context.getPlural(R.plurals.fmt_song_count, songs.size)
+ val description =
+ MediaDescriptionCompat.Builder()
+ .setMediaId(mediaSessionUID.toString())
+ .setTitle(name.resolve(context))
+ .setSubtitle(artists.resolveNames(context))
+ .setDescription(counts)
+ .setIconUri(cover.single.mediaStoreCoverUri)
+ .setExtras(extras)
+ .build()
+ return MediaItem(description, MediaItem.FLAG_BROWSABLE)
+}
+
+fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): 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 extras = makeExtras(context, *sugar)
+ val description =
+ MediaDescriptionCompat.Builder()
+ .setMediaId(mediaSessionUID.toString())
+ .setTitle(name.resolve(context))
+ .setSubtitle(counts)
+ .setDescription(genres.resolveNames(context))
+ .setIconUri(cover.single.mediaStoreCoverUri)
+ .setExtras(extras)
+ .build()
+ return MediaItem(description, MediaItem.FLAG_BROWSABLE)
+}
+
+fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): 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 extras = makeExtras(context, *sugar)
+ val description =
+ MediaDescriptionCompat.Builder()
+ .setMediaId(mediaSessionUID.toString())
+ .setTitle(name.resolve(context))
+ .setSubtitle(counts)
+ .setIconUri(cover.single.mediaStoreCoverUri)
+ .setExtras(extras)
+ .build()
+ return MediaItem(description, MediaItem.FLAG_BROWSABLE)
+}
+
+fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): 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 extras = makeExtras(context, *sugar)
+ val description =
+ MediaDescriptionCompat.Builder()
+ .setMediaId(mediaSessionUID.toString())
+ .setTitle(name.resolve(context))
+ .setSubtitle(counts)
+ .setDescription(durationMs.formatDurationDs(true))
+ .setIconUri(cover?.single?.mediaStoreCoverUri)
+ .setExtras(extras)
+ .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..35a43c7b5
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt
@@ -0,0 +1,242 @@
+/*
+ * 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.support.v4.media.MediaBrowserCompat.MediaItem
+import javax.inject.Inject
+import org.oxycblt.auxio.BuildConfig
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.detail.DetailGenerator
+import org.oxycblt.auxio.detail.DetailSection
+import org.oxycblt.auxio.home.HomeGenerator
+import org.oxycblt.auxio.list.adapter.UpdateInstructions
+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.MusicType
+import org.oxycblt.auxio.music.Playlist
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.info.resolveNumber
+import org.oxycblt.auxio.search.SearchEngine
+
+class MusicBrowser
+private constructor(
+ private val context: Context,
+ private val invalidator: Invalidator,
+ private val musicRepository: MusicRepository,
+ private val searchEngine: SearchEngine,
+ homeGeneratorFactory: HomeGenerator.Factory,
+ detailGeneratorFactory: DetailGenerator.Factory
+) : HomeGenerator.Invalidator, DetailGenerator.Invalidator {
+
+ class Factory
+ @Inject
+ constructor(
+ private val musicRepository: MusicRepository,
+ private val searchEngine: SearchEngine,
+ private val homeGeneratorFactory: HomeGenerator.Factory,
+ private val detailGeneratorFactory: DetailGenerator.Factory
+ ) {
+ fun create(context: Context, invalidator: Invalidator): MusicBrowser =
+ MusicBrowser(
+ context,
+ invalidator,
+ musicRepository,
+ searchEngine,
+ homeGeneratorFactory,
+ detailGeneratorFactory)
+ }
+
+ interface Invalidator {
+ fun invalidateMusic(ids: Set)
+ }
+
+ private val homeGenerator = homeGeneratorFactory.create(this)
+ private val detailGenerator = detailGeneratorFactory.create(this)
+
+ fun attach() {
+ homeGenerator.attach()
+ detailGenerator.attach()
+ }
+
+ fun release() {
+ homeGenerator.release()
+ detailGenerator.release()
+ }
+
+ override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
+ val id = MediaSessionUID.Tab(TabNode.Home(type)).toString()
+ invalidator.invalidateMusic(setOf(id))
+ }
+
+ override fun invalidateTabs() {
+ val rootId = MediaSessionUID.Tab(TabNode.Root).toString()
+ val moreId = MediaSessionUID.Tab(TabNode.More).toString()
+ invalidator.invalidateMusic(setOf(rootId, moreId))
+ }
+
+ override fun invalidate(type: MusicType, replace: Int?) {
+ val deviceLibrary = musicRepository.deviceLibrary ?: return
+ val userLibrary = musicRepository.userLibrary ?: return
+ val music =
+ when (type) {
+ MusicType.ALBUMS -> deviceLibrary.albums
+ MusicType.ARTISTS -> deviceLibrary.artists
+ MusicType.GENRES -> deviceLibrary.genres
+ MusicType.PLAYLISTS -> userLibrary.playlists
+ else -> return
+ }
+ if (music.isEmpty()) {
+ return
+ }
+ val ids = music.map { MediaSessionUID.SingleItem(it.uid).toString() }.toSet()
+ invalidator.invalidateMusic(ids)
+ }
+
+ fun getItem(mediaId: String): MediaItem? {
+ val music =
+ when (val uid = MediaSessionUID.fromString(mediaId)) {
+ is MediaSessionUID.Tab -> return uid.node.toMediaItem(context)
+ is MediaSessionUID.SingleItem ->
+ musicRepository.find(uid.uid)?.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)
+ }
+ }
+
+ fun getChildren(parentId: String, maxTabs: Int): List? {
+ val deviceLibrary = musicRepository.deviceLibrary
+ val userLibrary = musicRepository.userLibrary
+ if (deviceLibrary == null || userLibrary == null) {
+ return listOf()
+ }
+ return getMediaItemList(parentId, maxTabs)
+ }
+
+ suspend fun search(query: String): MutableList {
+ if (query.isEmpty()) {
+ return mutableListOf()
+ }
+ val deviceLibrary = musicRepository.deviceLibrary ?: return mutableListOf()
+ val userLibrary = musicRepository.userLibrary ?: return mutableListOf()
+ val items =
+ SearchEngine.Items(
+ deviceLibrary.songs,
+ deviceLibrary.albums,
+ deviceLibrary.artists,
+ deviceLibrary.genres,
+ userLibrary.playlists)
+ return searchEngine.search(items, query).toMediaItems()
+ }
+
+ private fun SearchEngine.Items.toMediaItems(): MutableList {
+ val music = mutableListOf()
+ if (songs != null) {
+ music.addAll(songs.map { it.toMediaItem(context, header(R.string.lbl_songs)) })
+ }
+ if (albums != null) {
+ music.addAll(albums.map { it.toMediaItem(context, header(R.string.lbl_albums)) })
+ }
+ if (artists != null) {
+ music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) })
+ }
+ if (genres != null) {
+ music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) })
+ }
+ if (playlists != null) {
+ music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) })
+ }
+ return music
+ }
+
+ private fun getMediaItemList(id: String, maxTabs: Int): List? {
+ return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
+ is MediaSessionUID.Tab -> {
+ getCategoryMediaItems(mediaSessionUID.node, maxTabs)
+ }
+ is MediaSessionUID.SingleItem -> {
+ getChildMediaItems(mediaSessionUID.uid)
+ }
+ null -> {
+ return null
+ }
+ }
+ }
+
+ private fun getCategoryMediaItems(node: TabNode, maxTabs: Int) =
+ when (node) {
+ is TabNode.Root -> {
+ val tabs = homeGenerator.tabs()
+ if (maxTabs < tabs.size) {
+ tabs.take(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } +
+ TabNode.More.toMediaItem(context)
+ } else {
+ tabs.map { TabNode.Home(it).toMediaItem(context) }
+ }
+ }
+ is TabNode.More -> {
+ homeGenerator.tabs().drop(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) }
+ }
+ is TabNode.Home ->
+ when (node.type) {
+ MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context) }
+ MusicType.ALBUMS -> homeGenerator.albums().map { it.toMediaItem(context) }
+ MusicType.ARTISTS -> homeGenerator.artists().map { it.toMediaItem(context) }
+ MusicType.GENRES -> homeGenerator.genres().map { it.toMediaItem(context) }
+ MusicType.PLAYLISTS -> homeGenerator.playlists().map { it.toMediaItem(context) }
+ }
+ }
+
+ private fun getChildMediaItems(uid: Music.UID): List? {
+ val detail = detailGenerator.any(uid) ?: return null
+ return detail.sections.flatMap { section ->
+ when (section) {
+ is DetailSection.Songs ->
+ section.items.map {
+ it.toMediaItem(context, header(section.stringRes), child(detail.parent))
+ }
+ is DetailSection.Albums ->
+ section.items.map { it.toMediaItem(context, header(section.stringRes)) }
+ is DetailSection.Artists ->
+ section.items.map { it.toMediaItem(context, header(section.stringRes)) }
+ is DetailSection.Discs ->
+ section.discs.flatMap { (disc, songs) ->
+ val discString = disc.resolveNumber(context)
+ songs.map { it.toMediaItem(context, header(discString)) }
+ }
+ else -> error("Unknown section type: $section")
+ }
+ }
+ }
+
+ companion object {
+ const val KEY_CHILD_OF = BuildConfig.APPLICATION_ID + ".key.CHILD_OF"
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt
new file mode 100644
index 000000000..7cdd3fb9e
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * MusicServiceFragment.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.media.MediaBrowserServiceCompat.BrowserRoot
+import androidx.media.MediaBrowserServiceCompat.Result
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.oxycblt.auxio.ForegroundListener
+import org.oxycblt.auxio.ForegroundServiceNotification
+import org.oxycblt.auxio.music.MusicRepository
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logW
+
+class MusicServiceFragment
+@Inject
+constructor(
+ private val context: Context,
+ foregroundListener: ForegroundListener,
+ private val invalidator: Invalidator,
+ indexerFactory: Indexer.Factory,
+ musicBrowserFactory: MusicBrowser.Factory,
+ private val musicRepository: MusicRepository
+) : MusicBrowser.Invalidator {
+ private val indexer = indexerFactory.create(context, foregroundListener)
+ private val musicBrowser = musicBrowserFactory.create(context, this)
+ private val dispatchJob = Job()
+ private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default)
+
+ data class Page(val num: Int, val size: Int)
+
+ class Factory
+ @Inject
+ constructor(
+ private val indexerFactory: Indexer.Factory,
+ private val musicBrowserFactory: MusicBrowser.Factory,
+ private val musicRepository: MusicRepository
+ ) {
+ fun create(
+ context: Context,
+ foregroundListener: ForegroundListener,
+ invalidator: Invalidator
+ ): MusicServiceFragment =
+ MusicServiceFragment(
+ context,
+ foregroundListener,
+ invalidator,
+ indexerFactory,
+ musicBrowserFactory,
+ musicRepository)
+ }
+
+ interface Invalidator {
+ fun invalidateMusic(mediaId: String)
+ }
+
+ fun attach() {
+ indexer.attach()
+ musicBrowser.attach()
+ }
+
+ fun release() {
+ dispatchJob.cancel()
+ musicBrowser.release()
+ indexer.release()
+ }
+
+ override fun invalidateMusic(ids: Set) {
+ ids.forEach { mediaId -> invalidator.invalidateMusic(mediaId) }
+ }
+
+ fun start() {
+ if (musicRepository.indexingState == null) {
+ musicRepository.requestIndex(true)
+ }
+ }
+
+ fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
+ indexer.createNotification(post)
+ }
+
+ fun getRoot() = BrowserRoot(MediaSessionUID.Tab(TabNode.Root).toString(), Bundle())
+
+ fun getItem(mediaId: String, result: Result) =
+ result.dispatch {
+ musicBrowser.getItem(
+ mediaId,
+ )
+ }
+
+ fun getChildren(
+ mediaId: String,
+ maxTabs: Int,
+ result: Result>,
+ page: Page?
+ ) = result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) }
+
+ fun search(query: String, result: Result>, page: Page?) =
+ result.dispatchAsync { musicBrowser.search(query).expose(page) }
+
+ private fun Result.dispatch(body: () -> T?) {
+ try {
+ val result = body()
+ if (result == null) {
+ logW("Result is null")
+ }
+ sendResult(result)
+ } catch (e: Exception) {
+ logD("Error while dispatching: $e")
+ sendResult(null)
+ }
+ }
+
+ private fun Result.dispatchAsync(body: suspend () -> T?) {
+ detach()
+ dispatchScope.launch {
+ try {
+ val result = body()
+ if (result == null) {
+ logW("Result is null")
+ }
+ sendResult(result)
+ } catch (e: Exception) {
+ logD("Error while dispatching: $e")
+ sendResult(null)
+ }
+ }
+ }
+
+ private fun List.expose(page: Page?): MutableList {
+ if (page == null) return toMutableList()
+ val start = page.num * page.size
+ val end = start + page.size
+ return if (start >= size) {
+ mutableListOf()
+ } else {
+ subList(start, end.coerceAtMost(size)).toMutableList()
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt
new file mode 100644
index 000000000..d13dd42be
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * TabNode.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 org.oxycblt.auxio.R
+import org.oxycblt.auxio.music.MusicType
+
+sealed class TabNode {
+ abstract val id: String
+ abstract val nameRes: Int
+ abstract val bitmapRes: Int?
+
+ override fun toString() = id
+
+ data object Root : TabNode() {
+ override val id = "root"
+ override val nameRes = R.string.info_app_name
+ override val bitmapRes = null
+
+ override fun toString() = id
+ }
+
+ data object More : TabNode() {
+ override val id = "more"
+ override val nameRes = R.string.lbl_more
+ override val bitmapRes = R.drawable.ic_more_bitmap_24
+ }
+
+ data class Home(val type: MusicType) : TabNode() {
+ override val id = "$ID/${type.intCode}"
+ override val bitmapRes: Int
+ get() =
+ when (type) {
+ MusicType.SONGS -> R.drawable.ic_song_bitmap_24
+ MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24
+ MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24
+ MusicType.GENRES -> R.drawable.ic_genre_bitmap_24
+ MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24
+ }
+
+ override val nameRes = type.nameRes
+
+ companion object {
+ const val ID = "home"
+ }
+ }
+
+ companion object {
+ fun fromString(str: String): TabNode? {
+ return when {
+ str == Root.id -> Root
+ str == More.id -> More
+ str.startsWith(Home.ID) -> {
+ val split = str.split("/")
+ if (split.size != 2) return null
+ val intCode = split[1].toIntOrNull() ?: return null
+ Home(MusicType.fromIntCode(intCode) ?: return null)
+ }
+ else -> null
+ }
+ }
+ }
+}
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 adf3a782c..2947052c2 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
@@ -46,8 +46,6 @@ import org.oxycblt.auxio.image.ImageSettings
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.msToSecs
import org.oxycblt.auxio.playback.persist.PersistenceRepository
@@ -92,7 +90,6 @@ class ExoPlaybackStateHolder(
fun attach() {
imageSettings.registerListener(this)
player.addListener(this)
- replayGainProcessor.attach()
playbackManager.registerStateHolder(this)
playbackSettings.registerListener(this)
musicRepository.addUpdateListener(this)
@@ -111,10 +108,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,10 +140,7 @@ class ExoPlaybackStateHolder(
} else {
emptyList()
}
- return RawQueue(
- heap.mapNotNull { it.toSong(deviceLibrary) },
- shuffledMapping,
- player.currentMediaItemIndex)
+ return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex)
}
override fun handleDeferred(action: DeferredPlayback): Boolean {
@@ -164,10 +154,18 @@ class ExoPlaybackStateHolder(
is DeferredPlayback.RestoreState -> {
logD("Restoring playback state")
restoreScope.launch {
- persistenceRepository.readState()?.let {
- // Apply the saved state on the main thread to prevent code expecting
- // state updates on the main thread from crashing.
- withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
+ val state = persistenceRepository.readState()
+ withContext(Dispatchers.Main) {
+ if (state != null) {
+ // Apply the saved state on the main thread to prevent code expecting
+ // state updates on the main thread from crashing.
+ playbackManager.applySavedState(state, false)
+ if (action.play) {
+ playbackManager.playing(true)
+ }
+ } else if (action.fallback != null) {
+ playbackManager.playDeferred(action.fallback)
+ }
}
}
}
@@ -219,7 +217,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) }
@@ -309,16 +307,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()
}
@@ -370,12 +368,6 @@ class ExoPlaybackStateHolder(
repeatMode: RepeatMode,
ack: StateAck.NewPlayback?
) {
- val resolve = resolveQueue()
- logD("${rawQueue.heap == resolve.heap}")
- logD("${rawQueue.shuffledMapping == resolve.shuffledMapping}")
- logD("${rawQueue.heapIndex == resolve.heapIndex}")
- logD("${rawQueue.isShuffled == resolve.isShuffled}")
- logD("${rawQueue == resolve}")
var sendNewPlaybackEvent = false
var shouldSeek = false
if (this.parent != parent) {
@@ -383,7 +375,7 @@ class ExoPlaybackStateHolder(
sendNewPlaybackEvent = 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()))
@@ -548,6 +540,50 @@ 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(
@@ -563,7 +599,7 @@ class ExoPlaybackStateHolder(
) {
fun create(): ExoPlaybackStateHolder {
// Since Auxio is a music player, only specify an audio renderer to save
- // battery/apk size/cache size
+ // battery/apk size/cache size]
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf(
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
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
new file mode 100644
index 000000000..b5724f6b4
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt
@@ -0,0 +1,512 @@
+/*
+ * Copyright (c) 2021 Auxio Project
+ * MediaSessionHolder.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.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Bitmap
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.annotation.DrawableRes
+import androidx.car.app.mediaextensions.MetadataExtras
+import androidx.core.app.NotificationCompat
+import androidx.media.app.NotificationCompat.MediaStyle
+import javax.inject.Inject
+import org.oxycblt.auxio.BuildConfig
+import org.oxycblt.auxio.ForegroundListener
+import org.oxycblt.auxio.ForegroundServiceNotification
+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.MusicParent
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.resolveNames
+import org.oxycblt.auxio.music.service.MediaSessionUID
+import org.oxycblt.auxio.music.service.toMediaDescription
+import org.oxycblt.auxio.playback.ActionMode
+import org.oxycblt.auxio.playback.PlaybackSettings
+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.util.logD
+import org.oxycblt.auxio.util.newBroadcastPendingIntent
+import org.oxycblt.auxio.util.newMainPendingIntent
+
+/**
+ * A component that mirrors the current playback state into the [MediaSessionCompat] and
+ * [NotificationComponent].
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class MediaSessionHolder
+private constructor(
+ private val context: Context,
+ private val foregroundListener: ForegroundListener,
+ private val playbackManager: PlaybackStateManager,
+ private val playbackSettings: PlaybackSettings,
+ private val bitmapProvider: BitmapProvider,
+ private val imageSettings: ImageSettings,
+ private val mediaSessionInterface: MediaSessionInterface
+) : PlaybackStateManager.Listener, ImageSettings.Listener, PlaybackSettings.Listener {
+
+ class Factory
+ @Inject
+ constructor(
+ private val playbackManager: PlaybackStateManager,
+ private val playbackSettings: PlaybackSettings,
+ private val bitmapProvider: BitmapProvider,
+ private val imageSettings: ImageSettings,
+ private val mediaSessionInterface: MediaSessionInterface
+ ) {
+ fun create(context: Context, foregroundListener: ForegroundListener) =
+ MediaSessionHolder(
+ context,
+ foregroundListener,
+ playbackManager,
+ playbackSettings,
+ bitmapProvider,
+ imageSettings,
+ mediaSessionInterface)
+ }
+
+ private val mediaSession = MediaSessionCompat(context, context.packageName)
+ val token: MediaSessionCompat.Token
+ get() = mediaSession.sessionToken
+
+ private val _notification = PlaybackNotification(context, mediaSession.sessionToken)
+ val notification: ForegroundServiceNotification
+ get() = _notification
+
+ fun attach() {
+ playbackManager.addListener(this)
+ playbackSettings.registerListener(this)
+ imageSettings.registerListener(this)
+ mediaSession.apply {
+ isActive = true
+ setQueueTitle(context.getString(R.string.lbl_queue))
+ setCallback(mediaSessionInterface)
+ }
+ }
+
+ /**
+ * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to
+ * the [NotificationComponent].
+ */
+ fun release() {
+ bitmapProvider.release()
+ playbackSettings.unregisterListener(this)
+ imageSettings.unregisterListener(this)
+ playbackManager.removeListener(this)
+ mediaSession.apply {
+ isActive = false
+ release()
+ }
+ }
+
+ // --- PLAYBACKSTATEMANAGER OVERRIDES ---
+
+ override fun onIndexMoved(index: Int) {
+ updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
+ invalidateSessionState()
+ }
+
+ override fun onQueueChanged(queue: List, index: Int, change: QueueChange) {
+ updateQueue(queue)
+ when (change.type) {
+ // Nothing special to do with mapping changes.
+ QueueChange.Type.MAPPING -> {}
+ // Index changed, ensure playback state's index changes.
+ QueueChange.Type.INDEX -> invalidateSessionState()
+ // Song changed, ensure metadata changes.
+ QueueChange.Type.SONG ->
+ updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
+ }
+ }
+
+ override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {
+ updateQueue(queue)
+ invalidateSessionState()
+ mediaSession.setShuffleMode(
+ if (isShuffled) {
+ PlaybackStateCompat.SHUFFLE_MODE_ALL
+ } else {
+ PlaybackStateCompat.SHUFFLE_MODE_NONE
+ })
+ invalidateSecondaryAction()
+ }
+
+ override fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) {
+ updateMediaMetadata(playbackManager.currentSong, parent)
+ updateQueue(queue)
+ invalidateSessionState()
+ }
+
+ override fun onProgressionChanged(progression: Progression) {
+ invalidateSessionState()
+ _notification.updatePlaying(playbackManager.progression.isPlaying)
+ if (!bitmapProvider.isBusy) {
+ foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
+ }
+ }
+
+ override fun onRepeatModeChanged(repeatMode: RepeatMode) {
+ mediaSession.setRepeatMode(
+ when (repeatMode) {
+ RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
+ RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
+ RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
+ })
+
+ invalidateSecondaryAction()
+ }
+
+ // --- SETTINGS OVERRIDES ---
+
+ override fun onImageSettingsChanged() {
+ // Need to reload the metadata cover.
+ updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
+ }
+
+ override fun onNotificationActionChanged() {
+ // Need to re-load the action shown in the notification.
+ invalidateSecondaryAction()
+ }
+
+ // --- MEDIASESSION OVERRIDES ---
+
+ // --- INTERNAL ---
+
+ /**
+ * Upload a new [MediaMetadataCompat] based on the current playback state to the
+ * [MediaSessionCompat] and [NotificationComponent].
+ *
+ * @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no [Song]
+ * is currently playing.
+ * @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null if
+ * playback is currently occuring from all songs.
+ */
+ private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
+ logD("Updating media metadata to $song with $parent")
+ if (song == null) {
+ // Nothing playing, reset the MediaSession and close the notification.
+ logD("Nothing playing, resetting media session")
+ mediaSession.setMetadata(emptyMetadata)
+ return
+ }
+
+ // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
+ // several times.
+ val title = song.name.resolve(context)
+ val artist = song.artists.resolveNames(context)
+ val album = song.album.name.resolve(context)
+ val builder =
+ MediaMetadataCompat.Builder()
+ .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
+ .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album)
+ // Note: We would leave the artist field null if it didn't exist and let downstream
+ // consumers handle it, but that would break the notification display.
+ .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
+ .putText(
+ MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
+ song.album.artists.resolveNames(context))
+ .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
+ .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
+ .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
+ .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context))
+ .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
+ .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
+ .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, album)
+ .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
+ .putText(
+ PlaybackNotification.KEY_PARENT,
+ parent?.name?.resolve(context) ?: context.getString(R.string.lbl_all_songs))
+ .putText(
+ MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID,
+ MediaSessionUID.SingleItem(song.artists[0].uid).toString())
+ .putText(
+ MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID,
+ MediaSessionUID.SingleItem(song.album.uid).toString())
+ // These fields are nullable and so we must check first before adding them to the fields.
+ song.track?.let {
+ logD("Adding track information")
+ builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
+ }
+ song.disc?.let {
+ logD("Adding disc information")
+ builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong())
+ }
+ song.date?.let {
+ logD("Adding date information")
+ builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString())
+ builder.putLong(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toLong())
+ }
+
+ // We are normally supposed to use URIs for album art, but that removes some of the
+ // nice things we can do like square cropping or high quality covers. Instead,
+ // we load a full-size bitmap into the media session and take the performance hit.
+ bitmapProvider.load(
+ song,
+ object : BitmapProvider.Target {
+ override fun onCompleted(bitmap: Bitmap?) {
+ logD("Bitmap loaded, applying media session and posting notification")
+ if (bitmap != null) {
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
+ }
+ val metadata = builder.build()
+ mediaSession.setMetadata(metadata)
+ _notification.updateMetadata(metadata)
+ foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
+ }
+ })
+ }
+
+ /**
+ * Upload a new queue to the [MediaSessionCompat].
+ *
+ * @param queue The current queue to upload.
+ */
+ private fun updateQueue(queue: List) {
+ val queueItems =
+ queue.mapIndexed { i, song ->
+ val description =
+ song.toMediaDescription(
+ context, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) })
+ // Store the item index so we can then use the analogous index in the
+ // playback state.
+ MediaSessionCompat.QueueItem(description, i.toLong())
+ }
+ logD("Uploading ${queueItems.size} songs to MediaSession queue")
+ mediaSession.setQueue(queueItems)
+ }
+
+ /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
+ private fun invalidateSessionState() {
+ logD("Updating media session playback state")
+
+ val state =
+ // InternalPlayer.State handles position/state information.
+ playbackManager.progression
+ .intoPlaybackState(PlaybackStateCompat.Builder())
+ .setActions(MediaSessionInterface.ACTIONS)
+ // Active queue ID corresponds to the indices we populated prior, use them here.
+ .setActiveQueueItemId(playbackManager.index.toLong())
+
+ // Android 13+ relies on custom actions in the notification.
+
+ // Add the secondary action (either repeat/shuffle depending on the configuration)
+ val secondaryAction =
+ when (playbackSettings.notificationAction) {
+ ActionMode.SHUFFLE -> {
+ logD("Using shuffle MediaSession action")
+ PlaybackStateCompat.CustomAction.Builder(
+ PlaybackActions.ACTION_INVERT_SHUFFLE,
+ context.getString(R.string.desc_shuffle),
+ if (playbackManager.isShuffled) {
+ R.drawable.ic_shuffle_on_24
+ } else {
+ R.drawable.ic_shuffle_off_24
+ })
+ }
+ else -> {
+ logD("Using repeat mode MediaSession action")
+ PlaybackStateCompat.CustomAction.Builder(
+ PlaybackActions.ACTION_INC_REPEAT_MODE,
+ context.getString(R.string.desc_change_repeat),
+ playbackManager.repeatMode.icon)
+ }
+ }
+ state.addCustomAction(secondaryAction.build())
+
+ // Add the exit action so the service can be closed
+ val exitAction =
+ PlaybackStateCompat.CustomAction.Builder(
+ PlaybackActions.ACTION_EXIT,
+ context.getString(R.string.desc_exit),
+ R.drawable.ic_close_24)
+ .build()
+ state.addCustomAction(exitAction)
+
+ mediaSession.setPlaybackState(state.build())
+ }
+
+ /** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
+ private fun invalidateSecondaryAction() {
+ logD("Invalidating secondary action")
+ invalidateSessionState()
+
+ when (playbackSettings.notificationAction) {
+ ActionMode.SHUFFLE -> {
+ logD("Using shuffle notification action")
+ _notification.updateShuffled(playbackManager.isShuffled)
+ }
+ else -> {
+ logD("Using repeat mode notification action")
+ _notification.updateRepeatMode(playbackManager.repeatMode)
+ }
+ }
+
+ if (!bitmapProvider.isBusy) {
+ logD("Not loading a bitmap, post the notification")
+ foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
+ }
+ }
+
+ companion object {
+ private val emptyMetadata = MediaMetadataCompat.Builder().build()
+ }
+}
+
+/**
+ * The playback notification component. Due to race conditions regarding notification updates, this
+ * component is not self-sufficient. [MediaSessionHolder] should be used instead of manage it.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+@SuppressLint("RestrictedApi")
+private class PlaybackNotification(
+ private val context: Context,
+ sessionToken: MediaSessionCompat.Token
+) : ForegroundServiceNotification(context, CHANNEL_INFO) {
+ init {
+ setSmallIcon(R.drawable.ic_auxio_24)
+ setCategory(NotificationCompat.CATEGORY_TRANSPORT)
+ setShowWhen(false)
+ setSilent(true)
+ setContentIntent(context.newMainPendingIntent())
+ setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+
+ addAction(buildRepeatAction(context, RepeatMode.NONE))
+ addAction(
+ buildAction(context, PlaybackActions.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24))
+ addAction(buildPlayPauseAction(context, true))
+ addAction(
+ buildAction(context, PlaybackActions.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24))
+ addAction(buildAction(context, PlaybackActions.ACTION_EXIT, R.drawable.ic_close_24))
+
+ setStyle(
+ MediaStyle(this).setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3))
+ }
+
+ override val code: Int
+ get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE
+
+ // --- STATE FUNCTIONS ---
+
+ /**
+ * Update the currently shown metadata in this notification.
+ *
+ * @param metadata The [MediaMetadataCompat] to display in this notification.
+ */
+ fun updateMetadata(metadata: MediaMetadataCompat) {
+ logD("Updating shown metadata")
+ setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
+ setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
+ setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
+ setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION))
+ }
+
+ /**
+ * Update the playing state shown in this notification.
+ *
+ * @param isPlaying Whether playback should be indicated as ongoing or paused.
+ */
+ fun updatePlaying(isPlaying: Boolean) {
+ logD("Updating playing state: $isPlaying")
+ mActions[2] = buildPlayPauseAction(context, isPlaying)
+ }
+
+ /**
+ * Update the secondary action in this notification to show the current [RepeatMode].
+ *
+ * @param repeatMode The current [RepeatMode].
+ */
+ fun updateRepeatMode(repeatMode: RepeatMode) {
+ logD("Applying repeat mode action: $repeatMode")
+ mActions[0] = buildRepeatAction(context, repeatMode)
+ }
+
+ /**
+ * Update the secondary action in this notification to show the current shuffle state.
+ *
+ * @param isShuffled Whether the queue is currently shuffled or not.
+ */
+ fun updateShuffled(isShuffled: Boolean) {
+ logD("Applying shuffle action: $isShuffled")
+ mActions[0] = buildShuffleAction(context, isShuffled)
+ }
+
+ // --- NOTIFICATION ACTION BUILDERS ---
+
+ private fun buildPlayPauseAction(
+ context: Context,
+ isPlaying: Boolean
+ ): NotificationCompat.Action {
+ val drawableRes =
+ if (isPlaying) {
+ R.drawable.ic_pause_24
+ } else {
+ R.drawable.ic_play_24
+ }
+ return buildAction(context, PlaybackActions.ACTION_PLAY_PAUSE, drawableRes)
+ }
+
+ private fun buildRepeatAction(
+ context: Context,
+ repeatMode: RepeatMode
+ ): NotificationCompat.Action {
+ return buildAction(context, PlaybackActions.ACTION_INC_REPEAT_MODE, repeatMode.icon)
+ }
+
+ private fun buildShuffleAction(
+ context: Context,
+ isShuffled: Boolean
+ ): NotificationCompat.Action {
+ val drawableRes =
+ if (isShuffled) {
+ R.drawable.ic_shuffle_on_24
+ } else {
+ R.drawable.ic_shuffle_off_24
+ }
+ return buildAction(context, PlaybackActions.ACTION_INVERT_SHUFFLE, drawableRes)
+ }
+
+ private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) =
+ NotificationCompat.Action.Builder(
+ iconRes, actionName, context.newBroadcastPendingIntent(actionName))
+ .build()
+
+ companion object {
+ const val KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT"
+
+ /** Notification channel used by solely the playback notification. */
+ private val CHANNEL_INFO =
+ ChannelInfo(
+ id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
+ nameRes = R.string.lbl_playback)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt
new file mode 100644
index 000000000..2ea4f8db2
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt
@@ -0,0 +1,326 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * MediaSessionInterface.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.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.MediaStore
+import android.support.v4.media.MediaDescriptionCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import org.apache.commons.text.similarity.JaroWinklerSimilarity
+import org.oxycblt.auxio.BuildConfig
+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.device.DeviceLibrary
+import org.oxycblt.auxio.music.info.Name
+import org.oxycblt.auxio.music.service.MediaSessionUID
+import org.oxycblt.auxio.music.service.MusicBrowser
+import org.oxycblt.auxio.music.user.UserLibrary
+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
+
+class MediaSessionInterface
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val playbackManager: PlaybackStateManager,
+ private val commandFactory: PlaybackCommand.Factory,
+ private val musicRepository: MusicRepository,
+) : MediaSessionCompat.Callback() {
+ private val jaroWinkler = JaroWinklerSimilarity()
+
+ override fun onPrepare() {
+ super.onPrepare()
+ // STUB, we already automatically prepare playback.
+ }
+
+ override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) {
+ super.onPrepareFromMediaId(mediaId, extras)
+ // STUB, can't tell when this is called
+ }
+
+ override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) {
+ super.onPrepareFromUri(uri, extras)
+ // STUB, can't tell when this is called
+ }
+
+ override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
+ super.onPlayFromUri(uri, extras)
+ // STUB, can't tell when this is called
+ }
+
+ override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
+ super.onPlayFromMediaId(mediaId, extras)
+ val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return
+ val parentUid =
+ extras?.getString(MusicBrowser.KEY_CHILD_OF)?.let { MediaSessionUID.fromString(it) }
+ val command = expandUidIntoCommand(uid, parentUid)
+ logD(extras?.getString(MusicBrowser.KEY_CHILD_OF))
+ playbackManager.play(requireNotNull(command) { "Invalid playback configuration" })
+ }
+
+ override fun onPrepareFromSearch(query: String?, extras: Bundle?) {
+ super.onPrepareFromSearch(query, extras)
+ // STUB, can't tell when this is called
+ }
+
+ override fun onPlayFromSearch(query: String, extras: Bundle) {
+ super.onPlayFromSearch(query, extras)
+ val deviceLibrary = musicRepository.deviceLibrary ?: return
+ val userLibrary = musicRepository.userLibrary ?: return
+ val command =
+ expandSearchInfoCommand(query.ifBlank { null }, extras, deviceLibrary, userLibrary)
+ playbackManager.play(requireNotNull(command) { "Invalid playback configuration" })
+ }
+
+ override fun onAddQueueItem(description: MediaDescriptionCompat) {
+ super.onAddQueueItem(description)
+ val deviceLibrary = musicRepository.deviceLibrary ?: return
+ val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return
+ val songUid =
+ when (uid) {
+ is MediaSessionUID.SingleItem -> uid.uid
+ else -> return
+ }
+ val song = deviceLibrary.songs.find { it.uid == songUid } ?: return
+ playbackManager.addToQueue(song)
+ }
+
+ override fun onRemoveQueueItem(description: MediaDescriptionCompat) {
+ super.onRemoveQueueItem(description)
+ val at = description.extras?.getInt(KEY_QUEUE_POS)
+ if (at != null) {
+ // Direct queue item removal w/preserved extras, we can explicitly remove
+ // the correct item rather than a duplicate elsewhere.
+ playbackManager.removeQueueItem(at)
+ return
+ }
+ // Non-queue item or queue item lost it's extras in transit, remove the first item
+ val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return
+ val songUid =
+ when (uid) {
+ is MediaSessionUID.SingleItem -> uid.uid
+ else -> return
+ }
+ val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid }
+ playbackManager.removeQueueItem(firstAt)
+ }
+
+ override fun onPlay() {
+ playbackManager.playing(true)
+ }
+
+ override fun onPause() {
+ playbackManager.playing(false)
+ }
+
+ override fun onSkipToNext() {
+ playbackManager.next()
+ }
+
+ override fun onSkipToPrevious() {
+ playbackManager.prev()
+ }
+
+ override fun onSkipToQueueItem(id: Long) {
+ playbackManager.goto(id.toInt())
+ }
+
+ override fun onSeekTo(position: Long) {
+ playbackManager.seekTo(position)
+ }
+
+ override fun onFastForward() {
+ playbackManager.next()
+ }
+
+ override fun onRewind() {
+ playbackManager.seekTo(0)
+ playbackManager.playing(true)
+ }
+
+ override fun onSetRepeatMode(repeatMode: Int) {
+ playbackManager.repeatMode(
+ when (repeatMode) {
+ PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL
+ PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL
+ PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK
+ else -> RepeatMode.NONE
+ })
+ }
+
+ override fun onSetShuffleMode(shuffleMode: Int) {
+ playbackManager.shuffled(
+ shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
+ shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
+ }
+
+ override fun onStop() {
+ // Get the service to shut down with the ACTION_EXIT intent
+ context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT))
+ }
+
+ override fun onCustomAction(action: String, extras: Bundle?) {
+ super.onCustomAction(action, extras)
+ // Service already handles intents from the old notification actions, easier to
+ // plug into that system.
+ context.sendBroadcast(Intent(action))
+ }
+
+ private fun expandUidIntoCommand(
+ uid: MediaSessionUID,
+ parentUid: MediaSessionUID?
+ ): PlaybackCommand? {
+ val unwrappedUid = (uid as? MediaSessionUID.SingleItem)?.uid ?: return null
+ val unwrappedParentUid = (parentUid as? MediaSessionUID.SingleItem)?.uid
+ val music = musicRepository.find(unwrappedUid) ?: return null
+ val parent = unwrappedParentUid?.let { musicRepository.find(it) as? MusicParent }
+ return expandMusicIntoCommand(music, parent)
+ }
+
+ @Suppress("DEPRECATION")
+ private fun expandSearchInfoCommand(
+ query: String?,
+ extras: Bundle,
+ deviceLibrary: DeviceLibrary,
+ userLibrary: UserLibrary
+ ): PlaybackCommand? {
+ if (query == null) {
+ // User just wanted to 'play some music', shuffle all
+ return commandFactory.all(ShuffleMode.ON)
+ }
+
+ when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) {
+ MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> {
+ val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE)
+ val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)
+ val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)
+ val best =
+ deviceLibrary.songs.maxByOrNull {
+ fuzzy(it.name, songQuery) +
+ fuzzy(it.album.name, albumQuery) +
+ it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) }
+ }
+ if (best != null) {
+ return expandSongIntoCommand(best, null)
+ }
+ }
+ MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> {
+ val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)
+ val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)
+ val best =
+ deviceLibrary.albums.maxByOrNull {
+ fuzzy(it.name, albumQuery) +
+ it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) }
+ }
+ if (best != null) {
+ return commandFactory.album(best, ShuffleMode.OFF)
+ }
+ }
+ MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> {
+ val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)
+ val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) }
+ if (best != null) {
+ return commandFactory.artist(best, ShuffleMode.OFF)
+ }
+ }
+ MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> {
+ val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE)
+ val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) }
+ if (best != null) {
+ return commandFactory.genre(best, ShuffleMode.OFF)
+ }
+ }
+ MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> {
+ val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST)
+ val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) }
+ if (best != null) {
+ return commandFactory.playlist(best, ShuffleMode.OFF)
+ }
+ }
+ else -> {}
+ }
+
+ val bestMusic =
+ (deviceLibrary.songs +
+ deviceLibrary.albums +
+ deviceLibrary.artists +
+ deviceLibrary.genres +
+ userLibrary.playlists)
+ .maxByOrNull { fuzzy(it.name, query) }
+ // TODO: Error out when we can't correctly resolve the query
+ return bestMusic?.let { expandMusicIntoCommand(it, null) }
+ ?: commandFactory.all(ShuffleMode.ON)
+ }
+
+ private fun fuzzy(name: Name, query: String?): Double =
+ query?.let { jaroWinkler.apply(name.resolve(context), it) } ?: 0.0
+
+ private fun expandMusicIntoCommand(music: Music, parent: MusicParent?) =
+ when (music) {
+ is Song -> expandSongIntoCommand(music, parent)
+ is Album -> commandFactory.album(music, ShuffleMode.IMPLICIT)
+ is Artist -> commandFactory.artist(music, ShuffleMode.IMPLICIT)
+ is Genre -> commandFactory.genre(music, ShuffleMode.IMPLICIT)
+ is Playlist -> commandFactory.playlist(music, ShuffleMode.IMPLICIT)
+ }
+
+ private fun expandSongIntoCommand(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)
+ }
+
+ companion object {
+ const val KEY_QUEUE_POS = BuildConfig.APPLICATION_ID + ".metadata.QUEUE_POS"
+ const val ACTIONS =
+ PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
+ PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
+ PlaybackStateCompat.ACTION_PLAY or
+ PlaybackStateCompat.ACTION_PAUSE or
+ PlaybackStateCompat.ACTION_PLAY_PAUSE or
+ PlaybackStateCompat.ACTION_SET_REPEAT_MODE or
+ PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or
+ PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
+ PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
+ PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or
+ PlaybackStateCompat.ACTION_SEEK_TO or
+ PlaybackStateCompat.ACTION_REWIND or
+ PlaybackStateCompat.ACTION_STOP
+ }
+}
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/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt
deleted file mode 100644
index 69f2aab6d..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt
+++ /dev/null
@@ -1,273 +0,0 @@
-/*
- * Copyright (c) 2024 Auxio Project
- * MediaSessionServiceFragment.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.app.Notification
-import android.content.Context
-import android.os.Bundle
-import androidx.media3.common.MediaItem
-import androidx.media3.session.CommandButton
-import androidx.media3.session.DefaultActionFactory
-import androidx.media3.session.DefaultMediaNotificationProvider
-import androidx.media3.session.LibraryResult
-import androidx.media3.session.MediaLibraryService
-import androidx.media3.session.MediaLibraryService.MediaLibrarySession
-import androidx.media3.session.MediaNotification
-import androidx.media3.session.MediaNotification.ActionFactory
-import androidx.media3.session.MediaSession
-import androidx.media3.session.MediaSession.ConnectionResult
-import androidx.media3.session.SessionCommand
-import androidx.media3.session.SessionResult
-import com.google.common.collect.ImmutableList
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
-import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.async
-import kotlinx.coroutines.guava.asListenableFuture
-import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.ForegroundListener
-import org.oxycblt.auxio.IntegerTable
-import org.oxycblt.auxio.R
-import org.oxycblt.auxio.music.service.MediaItemBrowser
-import org.oxycblt.auxio.playback.state.DeferredPlayback
-import org.oxycblt.auxio.playback.state.PlaybackStateManager
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.newMainPendingIntent
-
-class MediaSessionServiceFragment
-@Inject
-constructor(
- @ApplicationContext private val context: Context,
- private val playbackManager: PlaybackStateManager,
- private val actionHandler: PlaybackActionHandler,
- private val mediaItemBrowser: MediaItemBrowser,
- exoHolderFactory: ExoPlaybackStateHolder.Factory
-) :
- MediaLibrarySession.Callback,
- PlaybackActionHandler.Callback,
- MediaItemBrowser.Invalidator,
- PlaybackStateManager.Listener {
- private val waitJob = Job()
- private val waitScope = CoroutineScope(waitJob + Dispatchers.Default)
- private val exoHolder = exoHolderFactory.create()
-
- private lateinit var actionFactory: ActionFactory
- private val mediaNotificationProvider =
- DefaultMediaNotificationProvider.Builder(context)
- .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
- .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
- .setChannelName(R.string.lbl_playback)
- .setPlayDrawableResourceId(R.drawable.ic_play_24)
- .setPauseDrawableResourceId(R.drawable.ic_pause_24)
- .setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24)
- .setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24)
- .setContentIntent(context.newMainPendingIntent())
- .build()
- .also { it.setSmallIcon(R.drawable.ic_auxio_24) }
- private var foregroundListener: ForegroundListener? = null
-
- lateinit var mediaSession: MediaLibrarySession
- private set
-
- // --- MEDIASESSION CALLBACKS ---
-
- fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession {
- foregroundListener = listener
- mediaSession = createSession(service)
- service.addSession(mediaSession)
- actionFactory = DefaultActionFactory(service)
- playbackManager.addListener(this)
- exoHolder.attach()
- actionHandler.attach(this)
- mediaItemBrowser.attach(this)
- return mediaSession
- }
-
- fun handleTaskRemoved() {
- if (!playbackManager.progression.isPlaying) {
- playbackManager.endSession()
- }
- }
-
- fun handleNonNativeStart() {
- // At minimum we want to ensure an active playback state.
- // TODO: Possibly also force to go foreground?
- logD("Handling non-native start.")
- playbackManager.playDeferred(DeferredPlayback.RestoreState)
- }
-
- fun hasNotification(): Boolean = exoHolder.sessionOngoing
-
- fun createNotification(post: (MediaNotification) -> Unit) {
- val notification =
- mediaNotificationProvider.createNotification(
- mediaSession, mediaSession.customLayout, actionFactory) { notification ->
- post(wrapMediaNotification(notification))
- }
- post(wrapMediaNotification(notification))
- }
-
- fun release() {
- waitJob.cancel()
- mediaItemBrowser.release()
- actionHandler.release()
- exoHolder.release()
- playbackManager.removeListener(this)
- mediaSession.release()
- foregroundListener = null
- }
-
- private fun wrapMediaNotification(notification: MediaNotification): MediaNotification {
- // Pulled from MediaNotificationManager: Need to specify MediaSession token manually
- // in notification
- val fwkToken =
- mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token
- notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
- return notification
- }
-
- private fun createSession(service: MediaLibraryService) =
- MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this).build()
-
- 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,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture> =
- Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params))
-
- override fun onGetItem(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- mediaId: String
- ): ListenableFuture> {
- val result =
- mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
- ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
- return Futures.immediateFuture(result)
- }
-
- override fun onSetMediaItems(
- mediaSession: MediaSession,
- controller: MediaSession.ControllerInfo,
- mediaItems: MutableList,
- startIndex: Int,
- startPositionMs: Long
- ): ListenableFuture =
- Futures.immediateFuture(
- MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
-
- override fun onGetChildren(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- parentId: String,
- page: Int,
- pageSize: Int,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture>> {
- val children =
- mediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
- LibraryResult.ofItemList(it, params)
- }
- ?: LibraryResult.ofError>(
- LibraryResult.RESULT_ERROR_BAD_VALUE)
- return Futures.immediateFuture(children)
- }
-
- override fun onSearch(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- query: String,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture> =
- waitScope
- .async {
- mediaItemBrowser.prepareSearch(query, browser)
- // Invalidator will send the notify result
- LibraryResult.ofVoid()
- }
- .asListenableFuture()
-
- override fun onGetSearchResult(
- session: MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- query: String,
- page: Int,
- pageSize: Int,
- params: MediaLibraryService.LibraryParams?
- ) =
- waitScope
- .async {
- mediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
- LibraryResult.ofItemList(it, params)
- }
- ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
- }
- .asListenableFuture()
-
- override fun onSessionEnded() {
- foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
- }
-
- override fun onCustomLayoutChanged(layout: List) {
- mediaSession.setCustomLayout(layout)
- }
-
- override fun invalidate(ids: Map) {
- for (id in ids) {
- mediaSession.notifyChildrenChanged(id.key, id.value, null)
- }
- }
-
- override fun invalidate(
- controller: MediaSession.ControllerInfo,
- query: String,
- itemCount: Int
- ) {
- mediaSession.notifySearchResultChanged(controller, query, itemCount, null)
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt
deleted file mode 100644
index b65d980db..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt
+++ /dev/null
@@ -1,282 +0,0 @@
-/*
- * Copyright (c) 2024 Auxio Project
- * PlaybackActionHandler.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.annotation.SuppressLint
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.media.AudioManager
-import android.os.Bundle
-import androidx.core.content.ContextCompat
-import androidx.media3.common.Player
-import androidx.media3.session.CommandButton
-import androidx.media3.session.SessionCommand
-import androidx.media3.session.SessionCommands
-import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.R
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.playback.ActionMode
-import org.oxycblt.auxio.playback.PlaybackSettings
-import org.oxycblt.auxio.playback.state.PlaybackStateManager
-import org.oxycblt.auxio.playback.state.Progression
-import org.oxycblt.auxio.playback.state.RepeatMode
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.widgets.WidgetComponent
-import org.oxycblt.auxio.widgets.WidgetProvider
-
-class PlaybackActionHandler
-@Inject
-constructor(
- @ApplicationContext private val context: Context,
- private val playbackManager: PlaybackStateManager,
- private val playbackSettings: PlaybackSettings,
- private val widgetComponent: WidgetComponent
-) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
-
- interface Callback {
- fun onCustomLayoutChanged(layout: List)
- }
-
- private val systemReceiver =
- SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
- private var callback: Callback? = null
-
- @SuppressLint("WrongConstant")
- fun attach(callback: Callback) {
- this.callback = callback
- playbackManager.addListener(this)
- playbackSettings.registerListener(this)
- ContextCompat.registerReceiver(
- context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
- }
-
- fun release() {
- callback = null
- playbackManager.removeListener(this)
- playbackSettings.unregisterListener(this)
- context.unregisterReceiver(systemReceiver)
- widgetComponent.release()
- }
-
- fun withCommands(commands: SessionCommands) =
- commands
- .buildUpon()
- .add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
- .add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
- .add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY))
- .build()
-
- fun handleCommand(command: SessionCommand): Boolean {
- when (command.customAction) {
- PlaybackActions.ACTION_INC_REPEAT_MODE ->
- playbackManager.repeatMode(playbackManager.repeatMode.increment())
- PlaybackActions.ACTION_INVERT_SHUFFLE ->
- playbackManager.shuffled(!playbackManager.isShuffled)
- PlaybackActions.ACTION_EXIT -> playbackManager.endSession()
- else -> return false
- }
- return true
- }
-
- fun createCustomLayout(): List {
- val actions = mutableListOf()
-
- when (playbackSettings.notificationAction) {
- ActionMode.REPEAT -> {
- actions.add(
- CommandButton.Builder()
- .setIconResId(playbackManager.repeatMode.icon)
- .setDisplayName(context.getString(R.string.desc_change_repeat))
- .setSessionCommand(
- SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
- .setEnabled(true)
- .build())
- }
- ActionMode.SHUFFLE -> {
- actions.add(
- CommandButton.Builder()
- .setIconResId(
- if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
- else R.drawable.ic_shuffle_off_24)
- .setDisplayName(context.getString(R.string.lbl_shuffle))
- .setSessionCommand(
- SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
- .setEnabled(true)
- .build())
- }
- else -> {}
- }
-
- actions.add(
- CommandButton.Builder()
- .setIconResId(R.drawable.ic_skip_prev_24)
- .setDisplayName(context.getString(R.string.desc_skip_prev))
- .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
- .setEnabled(true)
- .build())
-
- actions.add(
- CommandButton.Builder()
- .setIconResId(R.drawable.ic_close_24)
- .setDisplayName(context.getString(R.string.desc_exit))
- .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
- .setEnabled(true)
- .build())
-
- return actions
- }
-
- override fun onPauseOnRepeatChanged() {
- super.onPauseOnRepeatChanged()
- callback?.onCustomLayoutChanged(createCustomLayout())
- }
-
- override fun onProgressionChanged(progression: Progression) {
- super.onProgressionChanged(progression)
- callback?.onCustomLayoutChanged(createCustomLayout())
- }
-
- override fun onRepeatModeChanged(repeatMode: RepeatMode) {
- super.onRepeatModeChanged(repeatMode)
- callback?.onCustomLayoutChanged(createCustomLayout())
- }
-
- override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {
- super.onQueueReordered(queue, index, isShuffled)
- callback?.onCustomLayoutChanged(createCustomLayout())
- }
-
- override fun onNotificationActionChanged() {
- super.onNotificationActionChanged()
- callback?.onCustomLayoutChanged(createCustomLayout())
- }
-}
-
-object PlaybackActions {
- const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
- const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
- const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
- const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
- const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
- const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
-}
-
-/**
- * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
- * active [IntentFilter] to be registered.
- */
-class SystemPlaybackReceiver(
- private val playbackManager: PlaybackStateManager,
- private val playbackSettings: PlaybackSettings,
- private val widgetComponent: WidgetComponent
-) : BroadcastReceiver() {
- private var initialHeadsetPlugEventHandled = false
-
- val intentFilter =
- IntentFilter().apply {
- addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
- addAction(AudioManager.ACTION_HEADSET_PLUG)
- addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
- addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
- addAction(PlaybackActions.ACTION_SKIP_PREV)
- addAction(PlaybackActions.ACTION_PLAY_PAUSE)
- addAction(PlaybackActions.ACTION_SKIP_NEXT)
- addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
- }
-
- override fun onReceive(context: Context, intent: Intent) {
- when (intent.action) {
- // --- SYSTEM EVENTS ---
-
- // Android has three different ways of handling audio plug events for some reason:
- // 1. ACTION_HEADSET_PLUG, which only works with wired headsets
- // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
- // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
- // a non-starter since both require me to display a permission prompt
- // 3. Some internal framework thing that also handles bluetooth headsets
- // Just use ACTION_HEADSET_PLUG.
- AudioManager.ACTION_HEADSET_PLUG -> {
- logD("Received headset plug event")
- when (intent.getIntExtra("state", -1)) {
- 0 -> pauseFromHeadsetPlug()
- 1 -> playFromHeadsetPlug()
- }
-
- initialHeadsetPlugEventHandled = true
- }
- AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
- logD("Received Headset noise event")
- pauseFromHeadsetPlug()
- }
-
- // --- AUXIO EVENTS ---
- PlaybackActions.ACTION_PLAY_PAUSE -> {
- logD("Received play event")
- playbackManager.playing(!playbackManager.progression.isPlaying)
- }
- PlaybackActions.ACTION_INC_REPEAT_MODE -> {
- logD("Received repeat mode event")
- playbackManager.repeatMode(playbackManager.repeatMode.increment())
- }
- PlaybackActions.ACTION_INVERT_SHUFFLE -> {
- logD("Received shuffle event")
- playbackManager.shuffled(!playbackManager.isShuffled)
- }
- PlaybackActions.ACTION_SKIP_PREV -> {
- logD("Received skip previous event")
- playbackManager.prev()
- }
- PlaybackActions.ACTION_SKIP_NEXT -> {
- logD("Received skip next event")
- playbackManager.next()
- }
- PlaybackActions.ACTION_EXIT -> {
- logD("Received exit event")
- playbackManager.endSession()
- }
- WidgetProvider.ACTION_WIDGET_UPDATE -> {
- logD("Received widget update event")
- widgetComponent.update()
- }
- }
- }
-
- private fun playFromHeadsetPlug() {
- // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
- // which would result in unexpected playback. Work around it by dropping the first
- // call to this function, which should come from that Intent.
- if (playbackSettings.headsetAutoplay &&
- playbackManager.currentSong != null &&
- initialHeadsetPlugEventHandled) {
- logD("Device connected, resuming")
- playbackManager.playing(true)
- }
- }
-
- private fun pauseFromHeadsetPlug() {
- if (playbackManager.currentSong != null) {
- logD("Device disconnected, pausing")
- playbackManager.playing(false)
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt
new file mode 100644
index 000000000..484cb8541
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * PlaybackActions.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 org.oxycblt.auxio.BuildConfig
+
+object PlaybackActions {
+ const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
+ const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
+ const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
+ const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
+ const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
+ const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
+}
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
new file mode 100644
index 000000000..04af2a40f
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * PlaybackServiceFragment.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.support.v4.media.session.MediaSessionCompat
+import javax.inject.Inject
+import kotlinx.coroutines.Job
+import org.oxycblt.auxio.ForegroundListener
+import org.oxycblt.auxio.ForegroundServiceNotification
+import org.oxycblt.auxio.IntegerTable
+import org.oxycblt.auxio.playback.state.DeferredPlayback
+import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.widgets.WidgetComponent
+
+class PlaybackServiceFragment
+private constructor(
+ private val context: Context,
+ private val foregroundListener: ForegroundListener,
+ private val playbackManager: PlaybackStateManager,
+ exoHolderFactory: ExoPlaybackStateHolder.Factory,
+ sessionHolderFactory: MediaSessionHolder.Factory,
+ widgetComponentFactory: WidgetComponent.Factory,
+ systemReceiverFactory: SystemPlaybackReceiver.Factory,
+) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener {
+ class Factory
+ @Inject
+ constructor(
+ private val playbackManager: PlaybackStateManager,
+ private val exoHolderFactory: ExoPlaybackStateHolder.Factory,
+ private val sessionHolderFactory: MediaSessionHolder.Factory,
+ private val widgetComponentFactory: WidgetComponent.Factory,
+ private val systemReceiverFactory: SystemPlaybackReceiver.Factory,
+ ) {
+ fun create(context: Context, foregroundListener: ForegroundListener) =
+ PlaybackServiceFragment(
+ context,
+ foregroundListener,
+ playbackManager,
+ exoHolderFactory,
+ sessionHolderFactory,
+ widgetComponentFactory,
+ systemReceiverFactory)
+ }
+
+ private val waitJob = Job()
+ private val exoHolder = exoHolderFactory.create()
+ private val sessionHolder = sessionHolderFactory.create(context, foregroundListener)
+ private val widgetComponent = widgetComponentFactory.create(context)
+ private val systemReceiver = systemReceiverFactory.create(context, widgetComponent)
+
+ // --- MEDIASESSION CALLBACKS ---
+
+ fun attach(): MediaSessionCompat.Token {
+ exoHolder.attach()
+ sessionHolder.attach()
+ widgetComponent.attach()
+ systemReceiver.attach()
+ playbackManager.addListener(this)
+ return sessionHolder.token
+ }
+
+ fun handleTaskRemoved() {
+ if (!playbackManager.progression.isPlaying) {
+ playbackManager.endSession()
+ }
+ }
+
+ fun start(startedBy: Int) {
+ // At minimum we want to ensure an active playback state.
+ // TODO: Possibly also force to go foreground?
+ logD("Handling non-native start.")
+ val action =
+ when (startedBy) {
+ IntegerTable.START_ID_ACTIVITY -> null
+ IntegerTable.START_ID_TASKER ->
+ DeferredPlayback.RestoreState(
+ play = true, fallback = DeferredPlayback.ShuffleAll)
+ // External services using Auxio better know what they are doing.
+ else -> DeferredPlayback.RestoreState(play = false)
+ }
+ if (action != null) {
+ logD("Initing service fragment using action $action")
+ playbackManager.playDeferred(action)
+ }
+ }
+
+ val notification: ForegroundServiceNotification?
+ get() = if (exoHolder.sessionOngoing) sessionHolder.notification else null
+
+ fun release() {
+ waitJob.cancel()
+ widgetComponent.release()
+ context.unregisterReceiver(systemReceiver)
+ sessionHolder.release()
+ exoHolder.release()
+ playbackManager.removeListener(this)
+ }
+
+ override fun onSessionEnded() {
+ foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt
new file mode 100644
index 000000000..4e7c214e0
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * SystemPlaybackReceiver.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.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.media.AudioManager
+import androidx.core.content.ContextCompat
+import javax.inject.Inject
+import org.oxycblt.auxio.playback.PlaybackSettings
+import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.widgets.WidgetComponent
+import org.oxycblt.auxio.widgets.WidgetProvider
+
+/**
+ * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
+ * active [IntentFilter] to be registered.
+ */
+class SystemPlaybackReceiver
+private constructor(
+ private val context: Context,
+ private val playbackManager: PlaybackStateManager,
+ private val playbackSettings: PlaybackSettings,
+ private val widgetComponent: WidgetComponent
+) : BroadcastReceiver() {
+ private var initialHeadsetPlugEventHandled = false
+
+ class Factory
+ @Inject
+ constructor(
+ private val playbackManager: PlaybackStateManager,
+ private val playbackSettings: PlaybackSettings
+ ) {
+ fun create(context: Context, widgetComponent: WidgetComponent) =
+ SystemPlaybackReceiver(context, playbackManager, playbackSettings, widgetComponent)
+ }
+
+ fun attach() {
+ ContextCompat.registerReceiver(
+ context, this, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED)
+ }
+
+ fun release() {
+ context.unregisterReceiver(this)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ // --- SYSTEM EVENTS ---
+
+ // Android has three different ways of handling audio plug events for some reason:
+ // 1. ACTION_HEADSET_PLUG, which only works with wired headsets
+ // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
+ // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
+ // a non-starter since both require me to display a permission prompt
+ // 3. Some internal framework thing that also handles bluetooth headsets
+ // Just use ACTION_HEADSET_PLUG.
+ AudioManager.ACTION_HEADSET_PLUG -> {
+ logD("Received headset plug event")
+ when (intent.getIntExtra("state", -1)) {
+ 0 -> pauseFromHeadsetPlug()
+ 1 -> playFromHeadsetPlug()
+ }
+
+ initialHeadsetPlugEventHandled = true
+ }
+ AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
+ logD("Received Headset noise event")
+ pauseFromHeadsetPlug()
+ }
+
+ // --- AUXIO EVENTS ---
+ PlaybackActions.ACTION_PLAY_PAUSE -> {
+ logD("Received play event")
+ playbackManager.playing(!playbackManager.progression.isPlaying)
+ }
+ PlaybackActions.ACTION_INC_REPEAT_MODE -> {
+ logD("Received repeat mode event")
+ playbackManager.repeatMode(playbackManager.repeatMode.increment())
+ }
+ PlaybackActions.ACTION_INVERT_SHUFFLE -> {
+ logD("Received shuffle event")
+ playbackManager.shuffled(!playbackManager.isShuffled)
+ }
+ PlaybackActions.ACTION_SKIP_PREV -> {
+ logD("Received skip previous event")
+ playbackManager.prev()
+ }
+ PlaybackActions.ACTION_SKIP_NEXT -> {
+ logD("Received skip next event")
+ playbackManager.next()
+ }
+ PlaybackActions.ACTION_EXIT -> {
+ logD("Received exit event")
+ playbackManager.endSession()
+ }
+ WidgetProvider.ACTION_WIDGET_UPDATE -> {
+ logD("Received widget update event")
+ widgetComponent.update()
+ }
+ }
+ }
+
+ private fun playFromHeadsetPlug() {
+ // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
+ // which would result in unexpected playback. Work around it by dropping the first
+ // call to this function, which should come from that Intent.
+ if (playbackSettings.headsetAutoplay &&
+ playbackManager.currentSong != null &&
+ initialHeadsetPlugEventHandled) {
+ logD("Device connected, resuming")
+ playbackManager.playing(true)
+ }
+ }
+
+ private fun pauseFromHeadsetPlug() {
+ if (playbackManager.currentSong != null) {
+ logD("Device disconnected, pausing")
+ playbackManager.playing(false)
+ }
+ }
+
+ private companion object {
+ val INTENT_FILTER =
+ IntentFilter().apply {
+ addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
+ addAction(AudioManager.ACTION_HEADSET_PLUG)
+ addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
+ addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
+ addAction(PlaybackActions.ACTION_SKIP_PREV)
+ addAction(PlaybackActions.ACTION_PLAY_PAUSE)
+ addAction(PlaybackActions.ACTION_SKIP_NEXT)
+ addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
index a9ca350ca..34c1d4927 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
@@ -281,7 +281,8 @@ data class QueueChange(val type: Type, val instructions: UpdateInstructions) {
/** Possible long-running background tasks handled by the background playback task. */
sealed interface DeferredPlayback {
/** Restore the previously saved playback state. */
- data object RestoreState : DeferredPlayback
+ data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) :
+ DeferredPlayback
/**
* Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
index 7853bcca3..32c6da8de 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
@@ -61,7 +61,7 @@ interface SearchEngine {
val artists: Collection? = null,
val genres: Collection? = null,
val playlists: Collection? = null
- )
+ ) {}
}
class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) :
diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt
new file mode 100644
index 000000000..1dd5c8997
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * Start.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.tasker
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.content.ContextCompat
+import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput
+import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig
+import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput
+import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput
+import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
+import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult
+import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess
+import org.oxycblt.auxio.AuxioService
+import org.oxycblt.auxio.IntegerTable
+import org.oxycblt.auxio.R
+
+class StartActionHelper(config: TaskerPluginConfig) :
+ TaskerPluginConfigHelperNoOutputOrInput(config) {
+ override val runnerClass: Class
+ get() = StartActionRunner::class.java
+
+ override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) {
+ blurbBuilder.append(context.getString(R.string.lng_tasker_start))
+ }
+}
+
+class ActivityConfigStartAction : Activity(), TaskerPluginConfigNoInput {
+ override val context
+ get() = applicationContext
+
+ private val taskerHelper by lazy { StartActionHelper(this) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ taskerHelper.finishForTasker()
+ }
+}
+
+class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() {
+ override fun run(context: Context, input: TaskerInput): TaskerPluginResult {
+ ContextCompat.startForegroundService(
+ context,
+ Intent(context, AuxioService::class.java)
+ .putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_TASKER))
+ while (!AuxioService.isForeground) {
+ Thread.sleep(100)
+ }
+ return TaskerPluginResultSucess()
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt
new file mode 100644
index 000000000..ac44e418d
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * WidgetBitmapTransformation.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.widgets
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import coil.size.Size
+import coil.transform.Transformation
+import kotlin.math.sqrt
+
+class WidgetBitmapTransformation(private val reduce: Float) : Transformation {
+ private val metrics = Resources.getSystem().displayMetrics
+ private val sw = metrics.widthPixels
+ private val sh = metrics.heightPixels
+ // Cap memory usage at 1.5 times the size of the display
+ // 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h
+ // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java
+ // Of course since OEMs randomly patch this check, we give a lot of slack.
+ private val maxBitmapArea = (1.5 * sw * sh / reduce).toInt()
+
+ override val cacheKey: String
+ get() = "WidgetBitmapTransformation:${maxBitmapArea}"
+
+ override suspend fun transform(input: Bitmap, size: Size): Bitmap {
+ if (size !== Size.ORIGINAL) {
+ // The widget loading stack basically discards the size parameter since there's no
+ // sane value from the get-go, all this transform does is actually dynamically apply
+ // the size cap so this transform must always be zero.
+ throw IllegalArgumentException("WidgetBitmapTransformation requires original size.")
+ }
+ val inputArea = input.width * input.height
+ if (inputArea != maxBitmapArea) {
+ val scale = sqrt(maxBitmapArea / inputArea.toDouble())
+ val newWidth = (input.width * scale).toInt()
+ val newHeight = (input.height * scale).toInt()
+ return Bitmap.createScaledBitmap(input, newWidth, newHeight, true)
+ }
+ return input
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
index 8179e2ab8..1ffe0d705 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
@@ -22,7 +22,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import coil.request.ImageRequest
-import dagger.hilt.android.qualifiers.ApplicationContext
+import coil.size.Size
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider
@@ -46,17 +46,28 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt)
*/
class WidgetComponent
-@Inject
-constructor(
- @ApplicationContext private val context: Context,
+private constructor(
+ private val context: Context,
private val imageSettings: ImageSettings,
private val bitmapProvider: BitmapProvider,
private val playbackManager: PlaybackStateManager,
private val uiSettings: UISettings
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener {
+ class Factory
+ @Inject
+ constructor(
+ private val imageSettings: ImageSettings,
+ private val bitmapProvider: BitmapProvider,
+ private val playbackManager: PlaybackStateManager,
+ private val uiSettings: UISettings
+ ) {
+ fun create(context: Context) =
+ WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings)
+ }
+
private val widgetProvider = WidgetProvider()
- init {
+ fun attach() {
playbackManager.addListener(this)
uiSettings.registerListener(this)
imageSettings.registerListener(this)
@@ -96,24 +107,19 @@ constructor(
0
}
- return if (cornerRadius > 0) {
- // If rounded, reduce the bitmap size further to obtain more pronounced
- // rounded corners.
- builder.size(getSafeRemoteViewsImageSize(context, 10f))
- val cornersTransformation =
- RoundedRectTransformation(cornerRadius.toFloat())
+ val transformations = buildList {
if (imageSettings.forceSquareCovers) {
- builder.transformations(
- SquareCropTransformation.INSTANCE, cornersTransformation)
+ add(SquareCropTransformation.INSTANCE)
+ }
+ if (cornerRadius > 0) {
+ add(WidgetBitmapTransformation(15f))
+ add(RoundedRectTransformation(cornerRadius.toFloat()))
} else {
- builder.transformations(cornersTransformation)
+ add(WidgetBitmapTransformation(3f))
}
- } else {
- if (imageSettings.forceSquareCovers) {
- builder.transformations(SquareCropTransformation.INSTANCE)
- }
- builder.size(getSafeRemoteViewsImageSize(context))
}
+
+ return builder.size(Size.ORIGINAL).transformations(transformations)
}
override fun onCompleted(bitmap: Bitmap?) {
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
index 3d02fa1ab..953af14c8 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
@@ -27,7 +27,6 @@ import android.widget.RemoteViews
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.LayoutRes
-import kotlin.math.sqrt
import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
@@ -46,24 +45,6 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews {
return views
}
-/**
- * Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming that
- * there is only one image.
- *
- * @param context [Context] required to perform calculation.
- * @param reduce Optional multiplier to reduce the image size. Recommended value is 3 to avoid
- * device-specific variations in memory limit.
- * @return The dimension of a bitmap that can be safely used in [RemoteViews].
- */
-fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 3f): Int {
- val metrics = context.resources.displayMetrics
- val sw = metrics.widthPixels
- val sh = metrics.heightPixels
- // Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse
- // that to obtain the image size.
- return sqrt((6f / 4f / reduce) * sw * sh).toInt()
-}
-
/**
* Set the background resource of a [RemoteViews] View.
*
diff --git a/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png
new file mode 100644
index 000000000..ee3339aa0
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png
new file mode 100644
index 000000000..fabe49c28
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png
new file mode 100644
index 000000000..16c932bde
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png
new file mode 100644
index 000000000..37710f06b
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png
new file mode 100644
index 000000000..b92809953
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6408e9478..d59a2038b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -153,6 +153,7 @@
Shuffle
Shuffle all
+ Start playback
OK
Cancel
@@ -162,6 +163,7 @@
Reset
Add
+ More
Path style
Absolute
@@ -205,6 +207,10 @@
Donate to the project to get your name added here!
Search your library…
+
+ Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately.
+ \n\nWARNING: Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app.
+
diff --git a/fastlane/metadata/android/en-US/changelogs/48.txt b/fastlane/metadata/android/en-US/changelogs/48.txt
new file mode 100644
index 000000000..44e3fc9d1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/48.txt
@@ -0,0 +1,3 @@
+Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements.
+This release fixes a critical bug with the music loader.
+For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.2
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/49.txt b/fastlane/metadata/android/en-US/changelogs/49.txt
new file mode 100644
index 000000000..62d3f517b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/49.txt
@@ -0,0 +1,3 @@
+Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements.
+This release adds basic Tasker integration while fixing a few issues that affected certain devices.
+For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3
diff --git a/fastlane/metadata/android/en-US/changelogs/50.txt b/fastlane/metadata/android/en-US/changelogs/50.txt
new file mode 100644
index 000000000..bd58c45f8
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/50.txt
@@ -0,0 +1,2 @@
+Auxio 3.6.0 improves support for android auto and fixes several small regressions.
+For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3