Compare commits

...

24 commits

Author SHA1 Message Date
Alexander Capehart
d394b76908 playback: fix more build errors 2024-09-24 18:55:24 -06:00
Alexander Capehart
9ef3c41bf8 playback: fix di error 2024-09-24 18:48:16 -06:00
Alexander Capehart
a7aae6a11e playback: connect listener to gapless queuer 2024-09-24 18:46:28 -06:00
Alexander Capehart
4d28fe51b5 playback: save on playing change again 2024-09-24 18:45:23 -06:00
Alexander Capehart
2c87aa5830 playback: reformat 2024-09-24 18:42:39 -06:00
Alexander Capehart
f245e33887 playback: restructure repeat mode/listeners 2024-09-24 18:40:18 -06:00
Alexander Capehart
b784250fed playback: move player dep module 2024-09-23 15:18:53 -06:00
Alexander Capehart
5d1111b12a playback: move player into module 2024-09-23 15:15:06 -06:00
Alexander Capehart
e32c687c61 playback: extract gapless playback impl
I need to make a setting to switch between gapless and single-item playback
to accomodate extremely large queues, so extract the crazy hacky queue
stuff into a new PlayerKernel construct.

Single-item will be added at a later point.
2024-09-23 11:46:54 -06:00
Alexander Capehart
34f7bc4886 all: reformat 2024-09-19 17:49:41 -06:00
Alexander Capehart
acd81d1c57 music: introduce icon for backport more tab 2024-09-19 17:49:07 -06:00
Alexander Capehart
1f5b202c5a service: avoid crash on death 2024-09-19 17:22:48 -06:00
Alexander Capehart
0ef2dafc29 service: decouple maxtab handling and ids
Simpler and more versatile.
2024-09-19 17:22:34 -06:00
Alexander Capehart
66fad791d5 music: simplify disc number resolution
Introduce a resolveDisc extension function to share disc name
resolution between detail/browser
2024-09-18 16:00:36 -06:00
Alexander Capehart
01bebfe63d home: dont show tab icons in phone mode 2024-09-18 15:56:40 -06:00
Alexander Capehart
c108ec7e12 home: fix broken item refresh 2024-09-18 15:56:22 -06:00
Alexander Capehart
e2b4f215cb detail: fix broken item refresh 2024-09-18 15:56:11 -06:00
Alexander Capehart
c7e18cdc6a detail: generate sort header w/discs 2024-09-18 15:32:27 -06:00
Alexander Capehart
8e6b49c8ec ui: attach to generators 2024-09-18 15:31:39 -06:00
Alexander Capehart
4accfaafaf detail: fix incorrect disc section generation 2024-09-18 15:31:17 -06:00
Alexander Capehart
4917330633 service: re-add attach pattern
Turns out I can't actually couple creation/attach without creating a
huge amount of variable issues.
2024-09-18 14:50:56 -06:00
Alexander Capehart
09588b3f38 all: reformat 2024-09-18 14:50:56 -06:00
Alexander Capehart
af812bc840 music: tear down menus
Only works on automotive OS, which I am not targeting right now.
2024-09-18 14:50:56 -06:00
Alexander Capehart
556ac243f0 all: reformat/fixes 2024-09-18 14:50:56 -06:00
31 changed files with 844 additions and 530 deletions

View file

@ -49,8 +49,9 @@ class AuxioService :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
playbackFragment = playbackFragmentFactory.create(this, this) playbackFragment = playbackFragmentFactory.create(this, this)
sessionToken = playbackFragment.token sessionToken = playbackFragment.attach()
musicFragment = musicFragmentFactory.create(this, this, this) musicFragment = musicFragmentFactory.create(this, this, this)
musicFragment.attach()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -80,7 +81,6 @@ class AuxioService :
super.onDestroy() super.onDestroy()
musicFragment.release() musicFragment.release()
playbackFragment.release() playbackFragment.release()
sessionToken = null
} }
override fun onGetRoot( override fun onGetRoot(
@ -88,9 +88,7 @@ class AuxioService :
clientUid: Int, clientUid: Int,
rootHints: Bundle? rootHints: Bundle?
): BrowserRoot { ): BrowserRoot {
val maximumRootChildLimit = return musicFragment.getRoot()
rootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4
return musicFragment.getRoot(maximumRootChildLimit)
} }
override fun onLoadItem(itemId: String, result: Result<MediaItem>) { override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
@ -98,7 +96,10 @@ class AuxioService :
} }
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) { override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
musicFragment.getChildren(parentId, result) val maximumRootChildLimit =
browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4)
?: 4
musicFragment.getChildren(parentId, maximumRootChildLimit, result)
} }
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) { override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) {

View file

@ -1,9 +1,26 @@
/*
* Copyright (c) 2024 Auxio Project
* DetailGenerator.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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.DiscHeader
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -18,15 +35,20 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import java.util.SortedMap
import javax.inject.Inject
interface DetailGenerator { interface DetailGenerator {
fun any(uid: Music.UID): Detail<out MusicParent>? fun any(uid: Music.UID): Detail<out MusicParent>?
fun album(uid: Music.UID): Detail<Album>? fun album(uid: Music.UID): Detail<Album>?
fun artist(uid: Music.UID): Detail<Artist>? fun artist(uid: Music.UID): Detail<Artist>?
fun genre(uid: Music.UID): Detail<Genre>? fun genre(uid: Music.UID): Detail<Genre>?
fun playlist(uid: Music.UID): Detail<Playlist>? fun playlist(uid: Music.UID): Detail<Playlist>?
fun attach()
fun release() fun release()
interface Factory { interface Factory {
@ -38,10 +60,10 @@ interface DetailGenerator {
} }
} }
class DetailGeneratorFactoryImpl @Inject constructor( class DetailGeneratorFactoryImpl
private val listSettings: ListSettings, @Inject
private val musicRepository: MusicRepository constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) :
) : DetailGenerator.Factory { DetailGenerator.Factory {
override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator = override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator =
DetailGeneratorImpl(invalidator, listSettings, musicRepository) DetailGeneratorImpl(invalidator, listSettings, musicRepository)
} }
@ -51,7 +73,7 @@ private class DetailGeneratorImpl(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository private val musicRepository: MusicRepository
) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener { ) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener {
init { override fun attach() {
listSettings.registerListener(this) listSettings.registerListener(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
} }
@ -102,7 +124,8 @@ private class DetailGeneratorImpl(
val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null
val songs = listSettings.albumSongSort.songs(album.songs) val songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc } val discs = songs.groupBy { it.disc }
val section = if (discs.size > 1 || discs.keys.first() != null) { val section =
if (discs.size > 1) {
DetailSection.Discs(discs) DetailSection.Discs(discs)
} else { } else {
DetailSection.Songs(songs) DetailSection.Songs(songs)
@ -138,11 +161,12 @@ private class DetailGeneratorImpl(
// implicit album list into the mapping. // implicit album list into the mapping.
logD("Implicit albums present, adding to list") logD("Implicit albums present, adding to list")
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
(grouping as MutableMap<DetailSection.Albums.Category, Collection<Album>>)[DetailSection.Albums.Category.APPEARANCES] = (grouping as MutableMap<DetailSection.Albums.Category, Collection<Album>>)[
artist.implicitAlbums DetailSection.Albums.Category.APPEARANCES] = artist.implicitAlbums
} }
val sections = grouping.mapTo(mutableListOf<DetailSection>()) { (category, albums) -> val sections =
grouping.mapTo(mutableListOf<DetailSection>()) { (category, albums) ->
DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums))
} }
val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs)) val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs))
@ -184,7 +208,8 @@ sealed interface DetailSection {
override val stringRes = R.string.lbl_songs override val stringRes = R.string.lbl_songs
} }
data class Albums(val category: Category, override val items: List<Album>) : PlainSection<Album>() { data class Albums(val category: Category, override val items: List<Album>) :
PlainSection<Album>() {
override val order = 1 + category.ordinal override val order = 1 + category.ordinal
override val stringRes = category.stringRes override val stringRes = category.stringRes
@ -203,7 +228,6 @@ sealed interface DetailSection {
} }
} }
data class Songs(override val items: List<Song>) : PlainSection<Song>() { data class Songs(override val items: List<Song>) : PlainSection<Song>() {
override val order = 12 override val order = 12
override val stringRes = R.string.lbl_songs override val stringRes = R.string.lbl_songs

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * Copyright (c) 2024 Auxio Project
* HomeModule.kt is part of Auxio. * DetailModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -72,9 +72,6 @@ constructor(
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator { ) : ViewModel(), DetailGenerator.Invalidator {
private val detailGenerator = detailGeneratorFactory.create(this)
private val _toShow = MutableEvent<Show>() private val _toShow = MutableEvent<Show>()
/** /**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently. * A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
@ -198,6 +195,12 @@ constructor(
playbackSettings.inParentPlaybackMode playbackSettings.inParentPlaybackMode
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
private val detailGenerator = detailGeneratorFactory.create(this)
init {
detailGenerator.attach()
}
override fun onCleared() { override fun onCleared() {
detailGenerator.release() detailGenerator.release()
} }
@ -208,21 +211,18 @@ constructor(
val album = detailGenerator.album(currentAlbum.value?.uid ?: return) val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace) refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
} }
MusicType.ARTISTS -> { MusicType.ARTISTS -> {
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return) val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) refreshDetail(
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
} }
MusicType.GENRES -> { MusicType.GENRES -> {
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return) val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace) refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
} }
MusicType.PLAYLISTS -> { MusicType.PLAYLISTS -> {
refreshPlaylist(currentPlaylist.value?.uid ?: return) refreshPlaylist(currentPlaylist.value?.uid ?: return)
} }
else -> error("Unexpected music type $type") else -> error("Unexpected music type $type")
} }
} }
@ -522,7 +522,6 @@ constructor(
} }
} }
private fun <T : MusicParent> refreshDetail( private fun <T : MusicParent> refreshDetail(
detail: Detail<T>?, detail: Detail<T>?,
parent: MutableStateFlow<T?>, parent: MutableStateFlow<T?>,
@ -537,22 +536,21 @@ constructor(
val newList = mutableListOf<Item>() val newList = mutableListOf<Item>()
var newInstructions: UpdateInstructions = UpdateInstructions.Diff var newInstructions: UpdateInstructions = UpdateInstructions.Diff
for ((i, section) in detail.sections.withIndex()) { for ((i, section) in detail.sections.withIndex()) {
val items = when (section) { val items =
when (section) {
is DetailSection.PlainSection<*> -> { is DetailSection.PlainSection<*> -> {
val header = if (section is DetailSection.Songs) val header =
SortHeader(section.stringRes) else BasicHeader(section.stringRes) if (section is DetailSection.Songs) SortHeader(section.stringRes)
else BasicHeader(section.stringRes)
newList.add(Divider(header)) newList.add(Divider(header))
newList.add(header) newList.add(header)
section.items section.items
} }
is DetailSection.Discs -> { is DetailSection.Discs -> {
val header = BasicHeader(section.stringRes) val header = SortHeader(section.stringRes)
newList.add(Divider(header)) newList.add(Divider(header))
newList.add(header) newList.add(header)
section.discs.flatMap { section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
listOf(DiscHeader(it.key)) + it.value
}
} }
} }
// Currently only the final section (songs, which can be sorted) are invalidatable // Currently only the final section (songs, which can be sorted) are invalidatable
@ -564,16 +562,20 @@ constructor(
newList.addAll(items) newList.addAll(items)
} }
parent.value = detail.parent parent.value = detail.parent
list.value = newList
instructions.put(newInstructions) instructions.put(newInstructions)
list.value = newList
} }
private fun refreshPlaylist(uid: Music.UID, instructions: UpdateInstructions = UpdateInstructions.Diff) { private fun refreshPlaylist(
uid: Music.UID,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
logD("Refreshing playlist list") logD("Refreshing playlist list")
val edited = editedPlaylist.value val edited = editedPlaylist.value
if (edited == null) { if (edited == null) {
val playlist = detailGenerator.playlist(uid) val playlist = detailGenerator.playlist(uid)
refreshDetail(playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) refreshDetail(
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null)
return return
} }
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
@ -583,8 +585,8 @@ constructor(
list.add(header) list.add(header)
list.addAll(edited) list.addAll(edited)
} }
_playlistSongList.value = list
_playlistSongInstructions.put(instructions) _playlistSongInstructions.put(instructions)
_playlistSongList.value = list
} }
} }

View file

@ -35,10 +35,10 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater 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. * 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) { fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner val disc = discHeader.inner
if (disc != null) { binding.discNumber.text = disc.resolveNumber(binding.context)
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
binding.discName.apply { binding.discName.apply {
text = disc.name text = disc?.name
isGone = disc.name == null 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
} }
} }

View file

@ -32,6 +32,10 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
interface HomeGenerator { interface HomeGenerator {
fun attach()
fun release()
fun songs(): List<Song> fun songs(): List<Song>
fun albums(): List<Album> fun albums(): List<Album>
@ -44,8 +48,6 @@ interface HomeGenerator {
fun tabs(): List<MusicType> fun tabs(): List<MusicType>
fun release()
interface Invalidator { interface Invalidator {
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
@ -74,41 +76,14 @@ private class HomeGeneratorImpl(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { ) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener {
override fun songs() = override fun attach() {
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<Tab.Visible>().map { it.type }
override fun onTabsChanged() {
invalidator.invalidateTabs()
}
init {
homeSettings.registerListener(this) homeSettings.registerListener(this)
listSettings.registerListener(this) listSettings.registerListener(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
} }
override fun release() { override fun onTabsChanged() {
musicRepository.removeUpdateListener(this) invalidator.invalidateTabs()
listSettings.unregisterListener(this)
homeSettings.unregisterListener(this)
} }
override fun onHideCollaboratorsChanged() { override fun onHideCollaboratorsChanged() {
@ -161,4 +136,31 @@ private class HomeGeneratorImpl(
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) 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<Tab.Visible>().map { it.type }
} }

View file

@ -52,8 +52,6 @@ constructor(
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
homeGeneratorFactory: HomeGenerator.Factory homeGeneratorFactory: HomeGenerator.Factory
) : ViewModel(), HomeGenerator.Invalidator { ) : ViewModel(), HomeGenerator.Invalidator {
private val homeGenerator = homeGeneratorFactory.create(this)
private val _songList = MutableStateFlow(listOf<Song>()) private val _songList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songList: StateFlow<List<Song>> val songList: StateFlow<List<Song>>
@ -131,6 +129,8 @@ constructor(
val playlistSort: Sort val playlistSort: Sort
get() = listSettings.playlistSort get() = listSettings.playlistSort
private val homeGenerator = homeGeneratorFactory.create(this)
/** /**
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s. * [Tab]s.
@ -163,6 +163,10 @@ constructor(
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
init {
homeGenerator.attach()
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
homeGenerator.release() homeGenerator.release()
@ -171,24 +175,24 @@ constructor(
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) { when (type) {
MusicType.SONGS -> { MusicType.SONGS -> {
_songList.value = homeGenerator.songs()
_songInstructions.put(instructions) _songInstructions.put(instructions)
_songList.value = homeGenerator.songs()
} }
MusicType.ALBUMS -> { MusicType.ALBUMS -> {
_albumList.value = homeGenerator.albums()
_albumInstructions.put(instructions) _albumInstructions.put(instructions)
_albumList.value = homeGenerator.albums()
} }
MusicType.ARTISTS -> { MusicType.ARTISTS -> {
_artistList.value = homeGenerator.artists()
_artistInstructions.put(instructions) _artistInstructions.put(instructions)
_artistList.value = homeGenerator.artists()
} }
MusicType.GENRES -> { MusicType.GENRES -> {
_genreList.value = homeGenerator.genres()
_genreInstructions.put(instructions) _genreInstructions.put(instructions)
_genreList.value = homeGenerator.genres()
} }
MusicType.PLAYLISTS -> { MusicType.PLAYLISTS -> {
_playlistList.value = homeGenerator.playlists()
_playlistInstructions.put(instructions) _playlistInstructions.put(instructions)
_playlistList.value = homeGenerator.playlists()
} }
} }
} }

View file

@ -52,7 +52,7 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
// On small screens, only display an icon. // On small screens, only display an icon.
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes) width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
// On large screens, display an icon and text. // On large screens, display an icon and text.
width < 600 -> tab.setText(homeTab.nameRes).setIcon(icon) width < 600 -> tab.setText(homeTab.nameRes)
// On medium-size screens, display text. // On medium-size screens, display text.
else -> tab.setIcon(icon).setText(homeTab.nameRes) else -> tab.setIcon(icon).setText(homeTab.nameRes)
} }

View file

@ -46,12 +46,19 @@ interface ListSettings : Settings<ListSettings.Listener> {
interface Listener { interface Listener {
fun onSongSortChanged() {} fun onSongSortChanged() {}
fun onAlbumSortChanged() {} fun onAlbumSortChanged() {}
fun onAlbumSongSortChanged() {} fun onAlbumSongSortChanged() {}
fun onArtistSortChanged() {} fun onArtistSortChanged() {}
fun onArtistSongSortChanged() {} fun onArtistSongSortChanged() {}
fun onGenreSortChanged() {} fun onGenreSortChanged() {}
fun onGenreSongSortChanged() {} fun onGenreSongSortChanged() {}
fun onPlaylistSortChanged() {} fun onPlaylistSortChanged() {}
} }
} }

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.music.info package org.oxycblt.auxio.music.info
import android.content.Context
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
/** /**
@ -34,3 +36,7 @@ class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
override fun compareTo(other: Disc) = number.compareTo(other.number) 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)

View file

@ -80,7 +80,7 @@ private constructor(
.newWakeLock( .newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
init { fun attach() {
musicSettings.registerListener(this) musicSettings.registerListener(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this) musicRepository.addIndexingListener(this)

View file

@ -18,17 +18,12 @@
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.view.MenuInflater
import androidx.annotation.MenuRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.view.menu.MenuBuilder
import androidx.core.view.children
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -83,46 +78,6 @@ sealed interface MediaSessionUID {
} }
} }
enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: Int) {
PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24),
SHUFFLE(
BuildConfig.APPLICATION_ID + ".menu.SHUFFLE",
R.string.lbl_shuffle,
R.drawable.ic_shuffle_off_24),
PLAY_NEXT(
BuildConfig.APPLICATION_ID + ".menu.PLAY_NEXT",
R.string.lbl_play_next,
R.drawable.ic_play_next_24),
ADD_TO_QUEUE(
BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE",
R.string.lbl_queue_add,
R.drawable.ic_queue_add_24),
DETAILS(
BuildConfig.APPLICATION_ID + ".menu.DETAILS",
R.string.lbl_parent_detail,
R.drawable.ic_details_24),
ALBUM_DETAILS(
BuildConfig.APPLICATION_ID + ".menu.ALBUM_DETAILS",
R.string.lbl_album_details,
R.drawable.ic_album_24),
ARTIST_DETAILS(
BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS",
R.string.lbl_artist_details,
R.drawable.ic_artist_24);
companion object {
val ITEM_ID_MAP =
mapOf(
R.id.action_play to PLAY,
R.id.action_shuffle to SHUFFLE,
R.id.action_play_next to PLAY_NEXT,
R.id.action_queue_add to ADD_TO_QUEUE,
R.id.action_detail to DETAILS,
R.id.action_album_details to ALBUM_DETAILS,
R.id.action_artist_details to ARTIST_DETAILS)
}
}
typealias Sugar = Bundle.(Context) -> Unit typealias Sugar = Bundle.(Context) -> Unit
fun header(@StringRes nameRes: Int): Sugar = { fun header(@StringRes nameRes: Int): Sugar = {
@ -138,16 +93,6 @@ private fun style(style: Int): Sugar = {
putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style)
} }
private fun menu(@MenuRes res: Int): Sugar = { context ->
@SuppressLint("RestrictedApi") val builder = MenuBuilder(context)
MenuInflater(context).inflate(res, builder)
val menuIds =
builder.children.mapNotNullTo(ArrayList()) {
BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId
}
putStringArrayList(MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds)
}
private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle {
return Bundle().apply { sugars.forEach { this.it(context) } } return Bundle().apply { sugars.forEach { this.it(context) } }
} }
@ -181,7 +126,7 @@ fun Song.toMediaDescription(
} else { } else {
MediaSessionUID.ChildItem(parent.uid, uid) MediaSessionUID.ChildItem(parent.uid, uid)
} }
val extras = makeExtras(context, *sugar, menu(R.menu.song)) val extras = makeExtras(context, *sugar)
return MediaDescriptionCompat.Builder() return MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
@ -212,7 +157,7 @@ fun Album.toMediaItem(
} else { } else {
MediaSessionUID.ChildItem(parent.uid, uid) MediaSessionUID.ChildItem(parent.uid, uid)
} }
val extras = makeExtras(context, *sugar, menu(R.menu.album)) val extras = makeExtras(context, *sugar)
val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size)
val description = val description =
MediaDescriptionCompat.Builder() MediaDescriptionCompat.Builder()
@ -241,7 +186,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
} else { } else {
context.getString(R.string.def_song_count) context.getString(R.string.def_song_count)
}) })
val extras = makeExtras(context, *sugar, menu(R.menu.parent)) val extras = makeExtras(context, *sugar)
val description = val description =
MediaDescriptionCompat.Builder() MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())
@ -262,7 +207,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
} else { } else {
context.getString(R.string.def_song_count) context.getString(R.string.def_song_count)
} }
val extras = makeExtras(context, *sugar, menu(R.menu.parent)) val extras = makeExtras(context, *sugar)
val description = val description =
MediaDescriptionCompat.Builder() MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())
@ -282,7 +227,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
} else { } else {
context.getString(R.string.def_song_count) context.getString(R.string.def_song_count)
} }
val extras = makeExtras(context, *sugar, menu(R.menu.playlist)) val extras = makeExtras(context, *sugar)
val description = val description =
MediaDescriptionCompat.Builder() MediaDescriptionCompat.Builder()
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())

View file

@ -24,13 +24,8 @@ import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailGenerator import org.oxycblt.auxio.detail.DetailGenerator
import org.oxycblt.auxio.detail.DetailSection import org.oxycblt.auxio.detail.DetailSection
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.home.HomeGenerator import org.oxycblt.auxio.home.HomeGenerator
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
@ -39,6 +34,7 @@ import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.search.SearchEngine
class MusicBrowser class MusicBrowser
@ -76,6 +72,11 @@ private constructor(
private val homeGenerator = homeGeneratorFactory.create(this) private val homeGenerator = homeGeneratorFactory.create(this)
private val detailGenerator = detailGeneratorFactory.create(this) private val detailGenerator = detailGeneratorFactory.create(this)
fun attach() {
homeGenerator.attach()
detailGenerator.attach()
}
fun release() { fun release() {
homeGenerator.release() homeGenerator.release()
detailGenerator.release() detailGenerator.release()
@ -87,18 +88,16 @@ private constructor(
} }
override fun invalidateTabs() { override fun invalidateTabs() {
for (i in 0..10) { val rootId = MediaSessionUID.Tab(TabNode.Root).toString()
// TODO: Temporary bodge, move the amount parameter to a bundle extra val moreId = MediaSessionUID.Tab(TabNode.More).toString()
val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString()
val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString()
invalidator.invalidateMusic(setOf(rootId, moreId)) invalidator.invalidateMusic(setOf(rootId, moreId))
} }
}
override fun invalidate(type: MusicType, replace: Int?) { override fun invalidate(type: MusicType, replace: Int?) {
val deviceLibrary = musicRepository.deviceLibrary ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return
val music = when (type) { val music =
when (type) {
MusicType.ALBUMS -> deviceLibrary.albums MusicType.ALBUMS -> deviceLibrary.albums
MusicType.ARTISTS -> deviceLibrary.artists MusicType.ARTISTS -> deviceLibrary.artists
MusicType.GENRES -> deviceLibrary.genres MusicType.GENRES -> deviceLibrary.genres
@ -133,14 +132,13 @@ private constructor(
} }
} }
fun getChildren(parentId: String): List<MediaItem>? { fun getChildren(parentId: String, maxTabs: Int): List<MediaItem>? {
val deviceLibrary = musicRepository.deviceLibrary val deviceLibrary = musicRepository.deviceLibrary
val userLibrary = musicRepository.userLibrary val userLibrary = musicRepository.userLibrary
if (deviceLibrary == null || userLibrary == null) { if (deviceLibrary == null || userLibrary == null) {
return listOf() return listOf()
} }
return getMediaItemList(parentId, maxTabs)
return getMediaItemList(parentId)
} }
suspend fun search(query: String): MutableList<MediaItem> { suspend fun search(query: String): MutableList<MediaItem> {
@ -179,10 +177,10 @@ private constructor(
return music return music
} }
private fun getMediaItemList(id: String): List<MediaItem>? { private fun getMediaItemList(id: String, maxTabs: Int): List<MediaItem>? {
return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
is MediaSessionUID.Tab -> { is MediaSessionUID.Tab -> {
getCategoryMediaItems(mediaSessionUID.node) getCategoryMediaItems(mediaSessionUID.node, maxTabs)
} }
is MediaSessionUID.SingleItem -> { is MediaSessionUID.SingleItem -> {
getChildMediaItems(mediaSessionUID.uid) getChildMediaItems(mediaSessionUID.uid)
@ -196,21 +194,21 @@ private constructor(
} }
} }
private fun getCategoryMediaItems(node: TabNode) = private fun getCategoryMediaItems(node: TabNode, maxTabs: Int) =
when (node) { when (node) {
is TabNode.Root -> { is TabNode.Root -> {
val tabs = homeGenerator.tabs() val tabs = homeGenerator.tabs()
val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } val base = tabs.take(maxTabs - 1).map { TabNode.Home(it) }
if (base.size < tabs.size) { if (base.size < tabs.size) {
base + TabNode.More(tabs.size - base.size) base + TabNode.More
} else { } else {
base base
} }
.map { it.toMediaItem(context) } .map { it.toMediaItem(context) }
} }
is TabNode.More -> is TabNode.More -> {
homeGenerator.tabs().takeLast(node.remainder).map { val tabs = homeGenerator.tabs()
TabNode.Home(it).toMediaItem(context) tabs.takeLast(tabs.size - maxTabs).map { TabNode.Home(it).toMediaItem(context) }
} }
is TabNode.Home -> is TabNode.Home ->
when (node.type) { when (node.type) {
@ -226,19 +224,16 @@ private constructor(
val detail = detailGenerator.any(uid) ?: return null val detail = detailGenerator.any(uid) ?: return null
return detail.sections.flatMap { section -> return detail.sections.flatMap { section ->
when (section) { when (section) {
is DetailSection.Songs -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } is DetailSection.Songs ->
is DetailSection.Albums -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } section.items.map { it.toMediaItem(context, null, header(section.stringRes)) }
is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Albums ->
is DetailSection.Discs -> section.discs.flatMap { section.items.map { it.toMediaItem(context, null, header(section.stringRes)) }
section.discs.flatMap { entry -> is DetailSection.Artists ->
val disc = entry.key section.items.map { it.toMediaItem(context, header(section.stringRes)) }
val discString = if (disc != null) { is DetailSection.Discs ->
context.getString(R.string.fmt_disc_no, disc.number) section.discs.flatMap { (disc, songs) ->
} else { val discString = disc.resolveNumber(context)
context.getString(R.string.def_disc) songs.map { it.toMediaItem(context, null, header(discString)) }
}
entry.value.map { it.toMediaItem(context, null, header(discString)) }
}
} }
else -> error("Unknown section type: $section") else -> error("Unknown section type: $section")
} }

View file

@ -23,7 +23,6 @@ import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaBrowserCompat.MediaItem
import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.BrowserRoot
import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.MediaBrowserServiceCompat.Result
import androidx.media.utils.MediaConstants
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -75,6 +74,11 @@ constructor(
fun invalidateMusic(mediaId: String) fun invalidateMusic(mediaId: String)
} }
fun attach() {
indexer.attach()
musicBrowser.attach()
}
fun release() { fun release() {
dispatchJob.cancel() dispatchJob.cancel()
musicBrowser.release() musicBrowser.release()
@ -95,30 +99,17 @@ constructor(
indexer.createNotification(post) indexer.createNotification(post)
} }
fun getRoot(maxItems: Int) = fun getRoot() = BrowserRoot(MediaSessionUID.Tab(TabNode.Root).toString(), Bundle())
BrowserRoot(
MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(),
Bundle().apply {
val actions =
BrowserOption.entries.mapTo(ArrayList()) {
Bundle().apply {
putString(
MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId)
putString(
MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL,
context.getString(it.labelRes))
}
}
putParcelableArrayList(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST,
actions)
})
fun getItem(mediaId: String, result: Result<MediaItem>) = fun getItem(mediaId: String, result: Result<MediaItem>) =
result.dispatch { musicBrowser.getItem(mediaId) } result.dispatch {
musicBrowser.getItem(
mediaId,
)
}
fun getChildren(mediaId: String, result: Result<MutableList<MediaItem>>) = fun getChildren(mediaId: String, maxTabs: Int, result: Result<MutableList<MediaItem>>) =
result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.toMutableList() }
fun search(query: String, result: Result<MutableList<MediaItem>>) = fun search(query: String, result: Result<MutableList<MediaItem>>) =
result.dispatchAsync { musicBrowser.search(query) } result.dispatchAsync { musicBrowser.search(query) }

View file

@ -23,37 +23,27 @@ import org.oxycblt.auxio.music.MusicType
sealed class TabNode { sealed class TabNode {
abstract val id: String abstract val id: String
abstract val data: Int
abstract val nameRes: Int abstract val nameRes: Int
abstract val bitmapRes: Int? abstract val bitmapRes: Int?
override fun toString() = "${id}/${data}" override fun toString() = id
data class Root(val amount: Int) : TabNode() { data object Root : TabNode() {
override val id = ID override val id = "root"
override val data = amount
override val nameRes = R.string.info_app_name override val nameRes = R.string.info_app_name
override val bitmapRes = null override val bitmapRes = null
companion object { override fun toString() = id
const val ID = "root"
}
} }
data class More(val remainder: Int) : TabNode() { data object More : TabNode() {
override val id = ID override val id = "more"
override val data = remainder
override val nameRes = R.string.lbl_more override val nameRes = R.string.lbl_more
override val bitmapRes = null override val bitmapRes = R.drawable.ic_more_bitmap_24
companion object {
const val ID = "more"
}
} }
data class Home(val type: MusicType) : TabNode() { data class Home(val type: MusicType) : TabNode() {
override val id = ID override val id = "$ID/${type.intCode}"
override val data = type.intCode
override val bitmapRes: Int override val bitmapRes: Int
get() = get() =
when (type) { when (type) {
@ -73,15 +63,15 @@ sealed class TabNode {
companion object { companion object {
fun fromString(str: String): TabNode? { fun fromString(str: String): TabNode? {
val split = str.split("/", limit = 2) return when {
if (split.size != 2) { str == Root.id -> Root
return null 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)
} }
val data = split[1].toIntOrNull() ?: return null
return when (split[0]) {
Root.ID -> Root(data)
More.ID -> More(data)
Home.ID -> Home(MusicType.fromIntCode(data) ?: return null)
else -> null else -> null
} }
} }

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.service package org.oxycblt.auxio.playback.player
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.exoplayer.source.ShuffleOrder import androidx.media3.exoplayer.source.ShuffleOrder

View file

@ -0,0 +1,227 @@
/*
* Copyright (c) 2024 Auxio Project
* GaplessQueuer.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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.player
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Player.RepeatMode
import androidx.media3.exoplayer.ExoPlayer
import javax.inject.Inject
import org.oxycblt.auxio.playback.PlaybackSettings
/** */
class GaplessQueuer
private constructor(
private val exoPlayer: ExoPlayer,
private val listener: Queuer.Listener,
private val playbackSettings: PlaybackSettings
) : Queuer, PlaybackSettings.Listener, Player.Listener {
class Factory @Inject constructor(private val playbackSettings: PlaybackSettings) :
Queuer.Factory {
override fun create(exoPlayer: ExoPlayer, listener: Queuer.Listener) =
GaplessQueuer(exoPlayer, listener, playbackSettings)
}
override val currentMediaItem: MediaItem? = exoPlayer.currentMediaItem
override val currentMediaItemIndex: Int = exoPlayer.currentMediaItemIndex
override val shuffleModeEnabled: Boolean = exoPlayer.shuffleModeEnabled
@get:RepeatMode
override var repeatMode: Int = exoPlayer.repeatMode
set(value) {
field = value
exoPlayer.repeatMode = value
updatePauseOnRepeat()
}
override fun attach() {
playbackSettings.registerListener(this)
exoPlayer.addListener(this)
}
override fun release() {
playbackSettings.unregisterListener(this)
exoPlayer.removeListener(this)
}
override fun computeHeap(): List<MediaItem> {
return (0 until exoPlayer.mediaItemCount).map { exoPlayer.getMediaItemAt(it) }
}
override fun computeMapping(): List<Int> {
val timeline = exoPlayer.currentTimeline
if (timeline.isEmpty) {
return emptyList()
}
val queue = mutableListOf<Int>()
// 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 = exoPlayer.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
}
override fun computeFirstMediaItemIndex() =
exoPlayer.currentTimeline.getFirstWindowIndex(exoPlayer.shuffleModeEnabled)
override fun goto(mediaItemIndex: Int) = exoPlayer.seekTo(mediaItemIndex, C.TIME_UNSET)
override fun seekToNext() = exoPlayer.seekToNext()
override fun hasNextMediaItem() = exoPlayer.hasNextMediaItem()
override fun seekToPrevious() = exoPlayer.seekToPrevious()
override fun seekToPreviousMediaItem() = exoPlayer.seekToPreviousMediaItem()
override fun hasPreviousMediaItem() = exoPlayer.hasPreviousMediaItem()
override fun prepareNew(mediaItems: List<MediaItem>, startIndex: Int?, shuffled: Boolean) {
exoPlayer.shuffleModeEnabled = shuffled
exoPlayer.setMediaItems(mediaItems)
if (shuffled) {
exoPlayer.setShuffleOrder(BetterShuffleOrder(mediaItems.size, startIndex ?: -1))
}
val target = startIndex ?: exoPlayer.currentTimeline.getFirstWindowIndex(shuffled)
exoPlayer.seekTo(target, C.TIME_UNSET)
exoPlayer.prepare()
}
override fun prepareSaved(
mediaItems: List<MediaItem>,
mapping: List<Int>,
index: Int,
shuffled: Boolean
) {
exoPlayer.setMediaItems(mediaItems)
if (shuffled) {
exoPlayer.shuffleModeEnabled = true
exoPlayer.setShuffleOrder(BetterShuffleOrder(mapping.toIntArray()))
} else {
exoPlayer.shuffleModeEnabled = false
}
exoPlayer.seekTo(index, C.TIME_UNSET)
exoPlayer.prepare()
}
override fun discard() {
exoPlayer.setMediaItems(emptyList())
}
override fun addTopMediaItems(mediaItems: List<MediaItem>) {
val currTimeline = exoPlayer.currentTimeline
val nextIndex =
if (currTimeline.isEmpty) {
C.INDEX_UNSET
} else {
currTimeline.getNextWindowIndex(
exoPlayer.currentMediaItemIndex,
Player.REPEAT_MODE_OFF,
exoPlayer.shuffleModeEnabled)
}
if (nextIndex == C.INDEX_UNSET) {
exoPlayer.addMediaItems(mediaItems)
} else {
exoPlayer.addMediaItems(nextIndex, mediaItems)
}
}
override fun addBottomMediaItems(mediaItems: List<MediaItem>) {
exoPlayer.addMediaItems(mediaItems)
}
override fun moveMediaItem(fromIndex: Int, toIndex: Int) {
// ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a
// semblance of "normalcy" by doing a weird no-op swap that actually moves the item.
when {
fromIndex > toIndex -> {
exoPlayer.moveMediaItem(fromIndex, toIndex)
exoPlayer.moveMediaItem(toIndex + 1, fromIndex)
}
toIndex > fromIndex -> {
exoPlayer.moveMediaItem(fromIndex, toIndex)
exoPlayer.moveMediaItem(toIndex - 1, fromIndex)
}
}
}
override fun removeMediaItem(index: Int) = exoPlayer.removeMediaItem(index)
override fun shuffled(shuffled: Boolean) {
exoPlayer.setShuffleModeEnabled(shuffled)
if (exoPlayer.shuffleModeEnabled) {
// Have to manually refresh the shuffle seed and anchor it to the new current songs
exoPlayer.setShuffleOrder(
BetterShuffleOrder(exoPlayer.mediaItemCount, exoPlayer.currentMediaItemIndex))
}
}
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
updatePauseOnRepeat()
}
private fun updatePauseOnRepeat() {
exoPlayer.pauseAtEndOfMediaItems =
exoPlayer.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (playbackState == Player.STATE_ENDED && exoPlayer.repeatMode == Player.REPEAT_MODE_OFF) {
goto(0)
exoPlayer.pause()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
listener.onAutoTransition()
}
}
}

View file

@ -0,0 +1,190 @@
/*
* Copyright (c) 2024 Auxio Project
* PlayerKernel.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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.MediaSource
import javax.inject.Inject
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
interface PlayerKernel {
val isPlaying: Boolean
var playWhenReady: Boolean
val currentPosition: Long
val audioSessionId: Int
val queuer: Queuer
fun attach()
fun release()
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun replaceQueuer(queuerFactory: Queuer.Factory)
interface Listener {
fun onPlayWhenReadyChanged()
fun onIsPlayingChanged()
fun onPositionDiscontinuity()
fun onError(error: PlaybackException)
}
interface Factory {
fun create(
context: Context,
playerListener: Listener,
queuerFactory: Queuer.Factory,
queuerListener: Queuer.Listener
): PlayerKernel
}
}
class PlayerKernelFactoryImpl
@Inject
constructor(
private val mediaSourceFactory: MediaSource.Factory,
private val replayGainProcessor: ReplayGainAudioProcessor
) : PlayerKernel.Factory {
override fun create(
context: Context,
playerListener: PlayerKernel.Listener,
queuerFactory: Queuer.Factory,
queuerListener: Queuer.Listener
): PlayerKernel {
// Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf(
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
MediaCodecAudioRenderer(
context,
MediaCodecSelector.DEFAULT,
handler,
audioListener,
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
replayGainProcessor))
}
val exoPlayer =
ExoPlayer.Builder(context, audioRenderer)
.setMediaSourceFactory(mediaSourceFactory)
// Enable automatic WakeLock support
.setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes(
// Signal that we are a music player.
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true)
.build()
return PlayerKernelImpl(
exoPlayer, replayGainProcessor, playerListener, queuerListener, queuerFactory)
}
}
private class PlayerKernelImpl(
private val exoPlayer: ExoPlayer,
private val replayGainProcessor: ReplayGainAudioProcessor,
private val playerListener: PlayerKernel.Listener,
private val queuerListener: Queuer.Listener,
queuerFactory: Queuer.Factory
) : PlayerKernel, Player.Listener {
override var queuer: Queuer = queuerFactory.create(exoPlayer, queuerListener)
override val isPlaying: Boolean
get() = exoPlayer.isPlaying
override var playWhenReady: Boolean
get() = exoPlayer.playWhenReady
set(value) {
exoPlayer.playWhenReady = value
}
override val currentPosition: Long
get() = exoPlayer.currentPosition
override val audioSessionId: Int
get() = exoPlayer.audioSessionId
override fun attach() {
exoPlayer.addListener(this)
replayGainProcessor.attach()
queuer.attach()
}
override fun release() {
queuer.release()
replayGainProcessor.release()
exoPlayer.release()
}
override fun play() = exoPlayer.play()
override fun pause() = exoPlayer.pause()
override fun seekTo(positionMs: Long) = exoPlayer.seekTo(positionMs)
override fun replaceQueuer(queuerFactory: Queuer.Factory) {
queuer.release()
queuer = queuerFactory.create(exoPlayer, queuerListener)
queuer.attach()
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
playerListener.onPlayWhenReadyChanged()
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
playerListener.onIsPlayingChanged()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
playerListener.onPositionDiscontinuity()
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
playerListener.onError(error)
}
}

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* SystemModule.kt is part of Auxio. * PlayerModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.service package org.oxycblt.auxio.playback.player
import android.content.Context import android.content.Context
import androidx.media3.datasource.ContentDataSource import androidx.media3.datasource.ContentDataSource
@ -32,6 +32,7 @@ import androidx.media3.extractor.mp4.Mp4Extractor
import androidx.media3.extractor.ogg.OggExtractor import androidx.media3.extractor.ogg.OggExtractor
import androidx.media3.extractor.ts.AdtsExtractor import androidx.media3.extractor.ts.AdtsExtractor
import androidx.media3.extractor.wav.WavExtractor import androidx.media3.extractor.wav.WavExtractor
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -40,7 +41,13 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class SystemModule { interface PlayerModule {
@Binds fun playerKernelFactory(factory: PlayerKernelFactoryImpl): PlayerKernel.Factory
}
@Module
@InstallIn(SingletonComponent::class)
class ExoPlayerModule {
@Provides @Provides
fun mediaSourceFactory( fun mediaSourceFactory(
dataSourceFactory: DataSource.Factory, dataSourceFactory: DataSource.Factory,

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * Copyright (c) 2024 Auxio Project
* ExoPlaybackStateHolder.kt is part of Auxio. * PlayerStateHolder.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -16,24 +16,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.service package org.oxycblt.auxio.playback.player
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.audiofx.AudioEffect import android.media.audiofx.AudioEffect
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.MediaSource
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -61,9 +51,10 @@ import org.oxycblt.auxio.playback.state.StateAck
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
class ExoPlaybackStateHolder( class PlayerStateHolder(
private val context: Context, private val context: Context,
private val player: ExoPlayer, playerKernelFactory: PlayerKernel.Factory,
gaplessQueuerFactory: GaplessQueuer.Factory,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val persistenceRepository: PersistenceRepository, private val persistenceRepository: PersistenceRepository,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
@ -73,30 +64,58 @@ class ExoPlaybackStateHolder(
private val imageSettings: ImageSettings private val imageSettings: ImageSettings
) : ) :
PlaybackStateHolder, PlaybackStateHolder,
Player.Listener, PlayerKernel.Listener,
Queuer.Listener,
MusicRepository.UpdateListener, MusicRepository.UpdateListener,
PlaybackSettings.Listener,
ImageSettings.Listener { ImageSettings.Listener {
class Factory
@Inject
constructor(
private val playbackManager: PlaybackStateManager,
private val persistenceRepository: PersistenceRepository,
private val playbackSettings: PlaybackSettings,
private val playerFactory: PlayerKernel.Factory,
private val gaplessQueuerFactory: GaplessQueuer.Factory,
private val commandFactory: PlaybackCommand.Factory,
private val replayGainProcessor: ReplayGainAudioProcessor,
private val musicRepository: MusicRepository,
private val imageSettings: ImageSettings,
) {
fun create(context: Context): PlayerStateHolder {
return PlayerStateHolder(
context,
playerFactory,
gaplessQueuerFactory,
playbackManager,
persistenceRepository,
playbackSettings,
commandFactory,
replayGainProcessor,
musicRepository,
imageSettings)
}
}
private val saveJob = Job() private val saveJob = Job()
private val saveScope = CoroutineScope(Dispatchers.IO + saveJob) private val saveScope = CoroutineScope(Dispatchers.IO + saveJob)
private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob) private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob)
private var currentSaveJob: Job? = null private var currentSaveJob: Job? = null
private var openAudioEffectSession = false private var openAudioEffectSession = false
private val player = playerKernelFactory.create(context, this, gaplessQueuerFactory, this)
var sessionOngoing = false var sessionOngoing = false
private set private set
init { fun attach() {
player.attach()
imageSettings.registerListener(this) imageSettings.registerListener(this)
player.addListener(this)
playbackManager.registerStateHolder(this) playbackManager.registerStateHolder(this)
playbackSettings.registerListener(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
} }
fun release() { fun release() {
saveJob.cancel() saveJob.cancel()
player.removeListener(this) player.release()
playbackManager.unregisterStateHolder(this) playbackManager.unregisterStateHolder(this)
musicRepository.removeUpdateListener(this) musicRepository.removeUpdateListener(this)
replayGainProcessor.release() replayGainProcessor.release()
@ -109,7 +128,7 @@ class ExoPlaybackStateHolder(
override val progression: Progression override val progression: Progression
get() { get() {
val mediaItem = player.currentMediaItem ?: return Progression.nil() val mediaItem = player.queuer.currentMediaItem ?: return Progression.nil()
val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE
val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration) val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration)
return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition) return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition)
@ -117,7 +136,7 @@ class ExoPlaybackStateHolder(
override val repeatMode override val repeatMode
get() = get() =
when (val repeatMode = player.repeatMode) { when (val repeatMode = player.queuer.repeatMode) {
Player.REPEAT_MODE_OFF -> RepeatMode.NONE Player.REPEAT_MODE_OFF -> RepeatMode.NONE
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
Player.REPEAT_MODE_ALL -> RepeatMode.ALL Player.REPEAT_MODE_ALL -> RepeatMode.ALL
@ -128,18 +147,11 @@ class ExoPlaybackStateHolder(
get() = player.audioSessionId get() = player.audioSessionId
override fun resolveQueue(): RawQueue { override fun resolveQueue(): RawQueue {
val deviceLibrary = val heap = player.queuer.computeHeap()
musicRepository.deviceLibrary
// No library, cannot do anything.
?: return RawQueue(emptyList(), emptyList(), 0)
val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) }
val shuffledMapping = val shuffledMapping =
if (player.shuffleModeEnabled) { if (player.queuer.shuffleModeEnabled) player.queuer.computeMapping() else emptyList()
player.unscrambleQueueIndices() return RawQueue(
} else { heap.mapNotNull { it.song }, shuffledMapping, player.queuer.currentMediaItemIndex)
emptyList()
}
return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex)
} }
override fun handleDeferred(action: DeferredPlayback): Boolean { override fun handleDeferred(action: DeferredPlayback): Boolean {
@ -202,43 +214,31 @@ class ExoPlaybackStateHolder(
} }
override fun repeatMode(repeatMode: RepeatMode) { override fun repeatMode(repeatMode: RepeatMode) {
player.repeatMode = player.queuer.repeatMode =
when (repeatMode) { when (repeatMode) {
RepeatMode.NONE -> Player.REPEAT_MODE_OFF RepeatMode.NONE -> Player.REPEAT_MODE_OFF
RepeatMode.ALL -> Player.REPEAT_MODE_ALL RepeatMode.ALL -> Player.REPEAT_MODE_ALL
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
} }
updatePauseOnRepeat()
playbackManager.ack(this, StateAck.RepeatModeChanged) playbackManager.ack(this, StateAck.RepeatModeChanged)
deferSave() deferSave()
} }
override fun newPlayback(command: PlaybackCommand) { override fun newPlayback(command: PlaybackCommand) {
parent = command.parent parent = command.parent
player.shuffleModeEnabled = command.shuffled val mediaItems = command.queue.map { it.buildMediaItem() }
player.setMediaItems(command.queue.map { it.buildMediaItem() })
val startIndex = val startIndex =
command.song command.song
?.let { command.queue.indexOf(it) } ?.let { command.queue.indexOf(it) }
.also { check(it != -1) { "Start song not in queue" } } .also { check(it != -1) { "Start song not in queue" } }
if (command.shuffled) { player.queuer.prepareNew(mediaItems, startIndex, command.shuffled)
player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1))
}
val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled)
player.seekTo(target, C.TIME_UNSET)
player.prepare()
player.play() player.play()
playbackManager.ack(this, StateAck.NewPlayback) playbackManager.ack(this, StateAck.NewPlayback)
deferSave() deferSave()
} }
override fun shuffled(shuffled: Boolean) { override fun shuffled(shuffled: Boolean) {
player.setShuffleModeEnabled(shuffled) player.queuer.shuffled(shuffled)
if (player.shuffleModeEnabled) {
// Have to manually refresh the shuffle seed and anchor it to the new current songs
player.setShuffleOrder(
BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
}
playbackManager.ack(this, StateAck.QueueReordered) playbackManager.ack(this, StateAck.QueueReordered)
deferSave() deferSave()
} }
@ -247,14 +247,14 @@ class ExoPlaybackStateHolder(
// Replicate the old pseudo-circular queue behavior when no repeat option is implemented. // Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
// Basically, you can't skip back and wrap around the queue, but you can skip forward and // Basically, you can't skip back and wrap around the queue, but you can skip forward and
// wrap around the queue, albeit playback will be paused. // wrap around the queue, albeit playback will be paused.
if (player.repeatMode == Player.REPEAT_MODE_ALL || player.hasNextMediaItem()) { if (player.queuer.repeatMode == Player.REPEAT_MODE_ALL ||
player.seekToNext() player.queuer.hasNextMediaItem()) {
player.queuer.seekToNext()
if (!playbackSettings.rememberPause) { if (!playbackSettings.rememberPause) {
player.play() player.play()
} }
} else { } else {
player.seekTo( player.queuer.goto(player.queuer.computeFirstMediaItemIndex())
player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET)
// TODO: Dislike the UX implications of this, I feel should I bite the bullet // TODO: Dislike the UX implications of this, I feel should I bite the bullet
// and switch to dynamic skip enable/disable? // and switch to dynamic skip enable/disable?
if (!playbackSettings.rememberPause) { if (!playbackSettings.rememberPause) {
@ -267,9 +267,9 @@ class ExoPlaybackStateHolder(
override fun prev() { override fun prev() {
if (playbackSettings.rewindWithPrev) { if (playbackSettings.rewindWithPrev) {
player.seekToPrevious() player.queuer.seekToPrevious()
} else if (player.hasPreviousMediaItem()) { } else if (player.queuer.hasPreviousMediaItem()) {
player.seekToPreviousMediaItem() player.queuer.seekToPreviousMediaItem()
} else { } else {
player.seekTo(0) player.seekTo(0)
} }
@ -281,13 +281,12 @@ class ExoPlaybackStateHolder(
} }
override fun goto(index: Int) { override fun goto(index: Int) {
val indices = player.unscrambleQueueIndices() val indices = player.queuer.computeMapping()
if (indices.isEmpty()) { if (indices.isEmpty()) {
return return
} }
val trueIndex = indices[index] val trueIndex = indices[index]
player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic player.queuer.goto(trueIndex)
if (!playbackSettings.rememberPause) { if (!playbackSettings.rememberPause) {
player.play() player.play()
} }
@ -296,63 +295,40 @@ class ExoPlaybackStateHolder(
} }
override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) { override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) {
val currTimeline = player.currentTimeline player.queuer.addBottomMediaItems(songs.map { it.buildMediaItem() })
val nextIndex =
if (currTimeline.isEmpty) {
C.INDEX_UNSET
} else {
currTimeline.getNextWindowIndex(
player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
}
if (nextIndex == C.INDEX_UNSET) {
player.addMediaItems(songs.map { it.buildMediaItem() })
} else {
player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
}
playbackManager.ack(this, ack) playbackManager.ack(this, ack)
deferSave() deferSave()
} }
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) { override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
player.addMediaItems(songs.map { it.buildMediaItem() }) player.queuer.addTopMediaItems(songs.map { it.buildMediaItem() })
playbackManager.ack(this, ack) playbackManager.ack(this, ack)
deferSave() deferSave()
} }
override fun move(from: Int, to: Int, ack: StateAck.Move) { override fun move(from: Int, to: Int, ack: StateAck.Move) {
val indices = player.unscrambleQueueIndices() val indices = player.queuer.computeMapping()
if (indices.isEmpty()) { if (indices.isEmpty()) {
return return
} }
val trueFrom = indices[from] val trueFrom = indices[from]
val trueTo = indices[to] val trueTo = indices[to]
// ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a
// semblance of "normalcy" by doing a weird no-op swap that actually moves the item. player.queuer.moveMediaItem(trueFrom, trueTo)
when {
trueFrom > trueTo -> {
player.moveMediaItem(trueFrom, trueTo)
player.moveMediaItem(trueTo + 1, trueFrom)
}
trueTo > trueFrom -> {
player.moveMediaItem(trueFrom, trueTo)
player.moveMediaItem(trueTo - 1, trueFrom)
}
}
playbackManager.ack(this, ack) playbackManager.ack(this, ack)
deferSave() deferSave()
} }
override fun remove(at: Int, ack: StateAck.Remove) { override fun remove(at: Int, ack: StateAck.Remove) {
val indices = player.unscrambleQueueIndices() val indices = player.queuer.computeMapping()
if (indices.isEmpty()) { if (indices.isEmpty()) {
return return
} }
val trueIndex = indices[at] val trueIndex = indices[at]
val songWillChange = player.currentMediaItemIndex == trueIndex val songWillChange = player.queuer.currentMediaItemIndex == trueIndex
player.removeMediaItem(trueIndex) player.queuer.removeMediaItem(trueIndex)
if (songWillChange && !playbackSettings.rememberPause) { if (songWillChange && !playbackSettings.rememberPause) {
player.play() player.play()
} }
@ -372,15 +348,11 @@ class ExoPlaybackStateHolder(
sendEvent = true sendEvent = true
} }
if (rawQueue != resolveQueue()) { if (rawQueue != resolveQueue()) {
player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) player.queuer.prepareSaved(
if (rawQueue.isShuffled) { rawQueue.heap.map { it.buildMediaItem() },
player.shuffleModeEnabled = true rawQueue.shuffledMapping,
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) rawQueue.heapIndex,
} else { rawQueue.isShuffled)
player.shuffleModeEnabled = false
}
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
player.prepare()
player.pause() player.pause()
sendEvent = true sendEvent = true
} }
@ -404,16 +376,14 @@ class ExoPlaybackStateHolder(
} }
override fun reset(ack: StateAck.NewPlayback) { override fun reset(ack: StateAck.NewPlayback) {
player.setMediaItems(listOf()) player.queuer.discard()
playbackManager.ack(this, ack) playbackManager.ack(this, ack)
deferSave() deferSave()
} }
// --- PLAYER OVERRIDES --- // --- PLAYER OVERRIDES ---
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { override fun onPlayWhenReadyChanged() {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (player.playWhenReady) { if (player.playWhenReady) {
// Mark that we have started playing so that the notification can now be posted. // Mark that we have started playing so that the notification can now be posted.
logD("Player has started playing") logD("Player has started playing")
@ -431,40 +401,27 @@ class ExoPlaybackStateHolder(
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = false openAudioEffectSession = false
} }
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
goto(0)
player.pause()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
playbackManager.ack(this, StateAck.IndexMoved)
}
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
// So many actions trigger progression changes that it becomes easier just to handle it
// in an ExoPlayer callback anyway. This doesn't really cause issues anywhere.
if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY)) {
logD("Player state changed, must synchronize state")
playbackManager.ack(this, StateAck.ProgressionChanged) playbackManager.ack(this, StateAck.ProgressionChanged)
} deferSave()
} }
override fun onPlayerError(error: PlaybackException) { override fun onIsPlayingChanged() {
playbackManager.ack(this, StateAck.ProgressionChanged)
deferSave()
}
override fun onPositionDiscontinuity() {
playbackManager.ack(this, StateAck.ProgressionChanged)
deferSave()
}
override fun onAutoTransition() {
playbackManager.ack(this, StateAck.IndexMoved)
deferSave()
}
override fun onError(error: PlaybackException) {
// TODO: Replace with no skipping and a notification instead // TODO: Replace with no skipping and a notification instead
// If there's any issue, just go to the next song. // If there's any issue, just go to the next song.
logE("Player error occurred") logE("Player error occurred")
@ -491,17 +448,7 @@ class ExoPlaybackStateHolder(
} }
} }
// --- PLAYBACKSETTINGS OVERRIDES --- // --- OVERRIDES ---
override fun onPauseOnRepeatChanged() {
super.onPauseOnRepeatChanged()
updatePauseOnRepeat()
}
private fun updatePauseOnRepeat() {
player.pauseAtEndOfMediaItems =
player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
}
private fun save(cb: () -> Unit) { private fun save(cb: () -> Unit) {
saveJob { saveJob {
@ -533,101 +480,6 @@ class ExoPlaybackStateHolder(
private val MediaItem.song: Song? private val MediaItem.song: Song?
get() = this.localConfiguration?.tag as? Song? get() = this.localConfiguration?.tag as? Song?
private fun Player.unscrambleQueueIndices(): List<Int> {
val timeline = currentTimeline
if (timeline.isEmpty) {
return emptyList()
}
val queue = mutableListOf<Int>()
// 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(
@ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager,
private val persistenceRepository: PersistenceRepository,
private val playbackSettings: PlaybackSettings,
private val commandFactory: PlaybackCommand.Factory,
private val mediaSourceFactory: MediaSource.Factory,
private val replayGainProcessorFactory: ReplayGainAudioProcessor.Factory,
private val musicRepository: MusicRepository,
private val imageSettings: ImageSettings,
) {
fun create(): ExoPlaybackStateHolder {
// Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size
val replayGainProcessor = replayGainProcessorFactory.create()
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf(
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
MediaCodecAudioRenderer(
context,
MediaCodecSelector.DEFAULT,
handler,
audioListener,
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
replayGainProcessor))
}
val exoPlayer =
ExoPlayer.Builder(context, audioRenderer)
.setMediaSourceFactory(mediaSourceFactory)
// Enable automatic WakeLock support
.setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes(
// Signal that we are a music player.
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true)
.build()
return ExoPlaybackStateHolder(
context,
exoPlayer,
playbackManager,
persistenceRepository,
playbackSettings,
commandFactory,
replayGainProcessor,
musicRepository,
imageSettings)
}
}
private companion object { private companion object {
const val SAVE_BUFFER = 5000L const val SAVE_BUFFER = 5000L
} }

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2024 Auxio Project
* Queuer.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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.player
import androidx.media3.common.MediaItem
import androidx.media3.common.Player.RepeatMode
import androidx.media3.exoplayer.ExoPlayer
interface Queuer {
val currentMediaItem: MediaItem?
val currentMediaItemIndex: Int
val shuffleModeEnabled: Boolean
@get:RepeatMode var repeatMode: Int
fun attach()
fun release()
fun goto(mediaItemIndex: Int)
fun seekToNext()
fun hasNextMediaItem(): Boolean
fun seekToPrevious()
fun seekToPreviousMediaItem()
fun hasPreviousMediaItem(): Boolean
fun moveMediaItem(fromIndex: Int, toIndex: Int)
fun removeMediaItem(index: Int)
// EXTENSIONS
fun computeHeap(): List<MediaItem>
fun computeMapping(): List<Int>
fun computeFirstMediaItemIndex(): Int
fun prepareNew(mediaItems: List<MediaItem>, startIndex: Int?, shuffled: Boolean)
fun prepareSaved(mediaItems: List<MediaItem>, mapping: List<Int>, index: Int, shuffled: Boolean)
fun discard()
fun addTopMediaItems(mediaItems: List<MediaItem>)
fun addBottomMediaItems(mediaItems: List<MediaItem>)
fun shuffled(shuffled: Boolean)
interface Listener {
fun onAutoTransition()
}
interface Factory {
fun create(exoPlayer: ExoPlayer, listener: Listener): Queuer
}
}

View file

@ -45,19 +45,11 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ReplayGainAudioProcessor class ReplayGainAudioProcessor
private constructor( @Inject
constructor(
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings private val playbackSettings: PlaybackSettings
) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener {
class Factory
@Inject
constructor(
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings
) {
fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings)
}
private var volume = 1f private var volume = 1f
set(value) { set(value) {
field = value field = value
@ -65,7 +57,7 @@ private constructor(
flush() flush()
} }
init { fun attach() {
playbackManager.addListener(this) playbackManager.addListener(this)
playbackSettings.registerListener(this) playbackSettings.registerListener(this)
} }

View file

@ -96,7 +96,7 @@ private constructor(
val notification: ForegroundServiceNotification val notification: ForegroundServiceNotification
get() = _notification get() = _notification
init { fun attach() {
playbackManager.addListener(this) playbackManager.addListener(this)
playbackSettings.registerListener(this) playbackSettings.registerListener(this)
imageSettings.registerListener(this) imageSettings.registerListener(this)

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.Job
import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.playback.player.PlayerStateHolder
import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -35,7 +36,7 @@ private constructor(
private val context: Context, private val context: Context,
private val foregroundListener: ForegroundListener, private val foregroundListener: ForegroundListener,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
exoHolderFactory: ExoPlaybackStateHolder.Factory, playerHolderFactory: PlayerStateHolder.Factory,
sessionHolderFactory: MediaSessionHolder.Factory, sessionHolderFactory: MediaSessionHolder.Factory,
widgetComponentFactory: WidgetComponent.Factory, widgetComponentFactory: WidgetComponent.Factory,
systemReceiverFactory: SystemPlaybackReceiver.Factory, systemReceiverFactory: SystemPlaybackReceiver.Factory,
@ -44,7 +45,7 @@ private constructor(
@Inject @Inject
constructor( constructor(
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val exoHolderFactory: ExoPlaybackStateHolder.Factory, private val exoHolderFactory: PlayerStateHolder.Factory,
private val sessionHolderFactory: MediaSessionHolder.Factory, private val sessionHolderFactory: MediaSessionHolder.Factory,
private val widgetComponentFactory: WidgetComponent.Factory, private val widgetComponentFactory: WidgetComponent.Factory,
private val systemReceiverFactory: SystemPlaybackReceiver.Factory, private val systemReceiverFactory: SystemPlaybackReceiver.Factory,
@ -61,18 +62,20 @@ private constructor(
} }
private val waitJob = Job() private val waitJob = Job()
private val exoHolder = exoHolderFactory.create() private val exoHolder = playerHolderFactory.create(context)
private val sessionHolder = sessionHolderFactory.create(context, foregroundListener) private val sessionHolder = sessionHolderFactory.create(context, foregroundListener)
private val widgetComponent = widgetComponentFactory.create(context) private val widgetComponent = widgetComponentFactory.create(context)
private val systemReceiver = systemReceiverFactory.create(context) private val systemReceiver = systemReceiverFactory.create(context, widgetComponent)
val token: MediaSessionCompat.Token
get() = sessionHolder.token
// --- MEDIASESSION CALLBACKS --- // --- MEDIASESSION CALLBACKS ---
init { fun attach(): MediaSessionCompat.Token {
exoHolder.attach()
sessionHolder.attach()
widgetComponent.attach()
systemReceiver.attach()
playbackManager.addListener(this) playbackManager.addListener(this)
return sessionHolder.token
} }
fun handleTaskRemoved() { fun handleTaskRemoved() {

View file

@ -37,6 +37,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider
*/ */
class SystemPlaybackReceiver class SystemPlaybackReceiver
private constructor( private constructor(
private val context: Context,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
private val widgetComponent: WidgetComponent private val widgetComponent: WidgetComponent
@ -47,16 +48,19 @@ private constructor(
@Inject @Inject
constructor( constructor(
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings
private val widgetComponent: WidgetComponent
) { ) {
fun create(context: Context): SystemPlaybackReceiver { fun create(context: Context, widgetComponent: WidgetComponent) =
val receiver = SystemPlaybackReceiver(context, playbackManager, playbackSettings, widgetComponent)
SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
ContextCompat.registerReceiver(
context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED)
return receiver
} }
fun attach() {
ContextCompat.registerReceiver(
context, this, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED)
}
fun release() {
context.unregisterReceiver(this)
} }
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {

View file

@ -67,7 +67,7 @@ private constructor(
private val widgetProvider = WidgetProvider() private val widgetProvider = WidgetProvider()
init { fun attach() {
playbackManager.addListener(this) playbackManager.addListener(this)
uiSettings.registerListener(this) uiSettings.registerListener(this)
imageSettings.registerListener(this) imageSettings.registerListener(this)

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B