Document code

Heavily document code that wasnt documented before.
This commit is contained in:
OxygenCobalt 2020-12-19 11:22:15 -07:00
parent 5d35dc8aa2
commit 953e1291b6
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
52 changed files with 878 additions and 254 deletions

View file

@ -23,10 +23,10 @@ These should also be logged in the [Issues](https://github.com/OxygenCobalt/Aux
Please keep in mind when requesting a feature:
- **Has it already been requested?** Make sure request for this feature is not already here.
- **Has it been already added?** Make sure this feature has not already been added in the most recent release.
- **Will it be accepted?** Read the [Accepted Additions and Requests](../info/ADDITIONS.md) in order to see the likelyhood that your request will be accepted.
- **Will it be accepted?** Read the [Accepted Additions and Requests](../info/ADDITIONS.md) in order to see the likelihood that your request will be accepted.
If you do make a request, provide the following:
- What kind of addition is this?
- What kind of addition is this? (A Full Feature? A new customization option? A UI Change?)
- What is it that you want?
- Why do you think it will benefit everyone's usage of the app?
@ -48,4 +48,4 @@ If you have knowledge of Android/Kotlin, feel free to to contribute to the proje
- Please ***FULLY TEST*** your changes before creating a PR. Untested code will not be merged.
- Java code will **NOT** be accepted. Kotlin only.
- Keep your code up the date with the upstream and continue to maintain it after you create the PR. This makes it less of a hassle to merge.
- Make sure you have read [Accepted Additions and Requests](../info/ADDITIONS.md) before working on your addition.
- Make sure you have read about the [Accepted Additions and Requests](../info/ADDITIONS.md) before working on your addition.

View file

@ -14,9 +14,9 @@
## About
Auxio is a music player for android that I built for myself, its intended to only have the features that I use out of a music player and nothing more.
Auxio is a local music player for android that I primarily built for myself.
However, Auxio is also structured to be customizable and extendable, in the case you want to add features that I personally don't need.
It only has the features that I use out of a music player and nothing more, with a UI/UX mostly derived from [Spotify](https://spotify.com), but with elements from [Phonograph](https://github.com/kabouzeid/Phonograph) and [Music Player GO](https://github.com/OxygenCobalt/Auxio). Its meant to be consistent and reliable, while still being customizable and extendable if one wants to add their own features that I (Personally) don't need.
## Screenshots
@ -33,7 +33,7 @@ However, Auxio is also structured to be customizable and extendable, in the case
## Features
- Reliable, ExoPlayer based playback
- Reliable, [ExoPlayer](https://exoplayer.dev/) based playback
- Customizable UI & Behavior
- Extensive Genres/Artists/Albums/Songs support
- Powerful queue system
@ -50,11 +50,14 @@ However, Auxio is also structured to be customizable and extendable, in the case
- Better music loading system
- Improved genre/artist/album UIs
- Dedicated search tab
- Better edge-to-edge support
- Swipe-to-next-track function
- Artist Images
- Black theme
- Custom accents
- Playlists
- Liked songs
- More notification actions
- Better edge-to-edge support
- More customization options
- Other things, presumably

View file

@ -14,9 +14,9 @@ import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.isEdgeOn
// FIXME: Fix bug where fast navigation will break the animations and
// lead to nothing being displayed [Possibly Un-fixable]
// FIXME: Compat issue with Versions 5 that leads to progress bar looking off
/**
* The single [AppCompatActivity] for Auxio.
*/
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View file

@ -23,8 +23,10 @@ import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.getTransparentAccent
import org.oxycblt.auxio.ui.isLandscape
import org.oxycblt.auxio.ui.toColor
import kotlin.IllegalArgumentException
/**
* The primary "Home" [Fragment] for Auxio.
*/
class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
@ -38,6 +40,7 @@ class MainFragment : Fragment() {
// If the music was cleared while the app was closed [Likely due to Auxio being suspended
// in the background], then navigate back to LoadingFragment to reload the music.
// This may actually not happen in normal use, but its a good failsafe.
if (MusicStore.getInstance().songs.isEmpty()) {
findNavController().navigate(MainFragmentDirections.actionReturnToLoading())
@ -111,9 +114,10 @@ class MainFragment : Fragment() {
return binding.root
}
// Functions that check if MainFragment should nav over to LibraryFragment, or whether
// it should stay put. Mostly by checking if the navController is currently in a detail
// fragment, and if the playing item is already being shown.
/**
* Whether its okay to navigate to the album detail fragment when the playing song/album needs to
* be navigated to
*/
private fun shouldGoToAlbum(controller: NavController): Boolean {
return (
controller.currentDestination!!.id == R.id.album_detail_fragment &&
@ -123,6 +127,10 @@ class MainFragment : Fragment() {
controller.currentDestination!!.id == R.id.genre_detail_fragment
}
/**
* Whether its okay to go to the artist detail fragment when the current playing artist
* is selected.
*/
private fun shouldGoToArtist(controller: NavController): Boolean {
return (
controller.currentDestination!!.id == R.id.artist_detail_fragment &&
@ -132,6 +140,9 @@ class MainFragment : Fragment() {
controller.currentDestination!!.id == R.id.genre_detail_fragment
}
/**
* Custom navigator code that has proper animations, unlike BottomNavigationView.setupWithNavController().
*/
private fun navigateWithItem(navController: NavController, item: MenuItem): Boolean {
if (navController.currentDestination!!.id != item.itemId) {
// Create custom NavOptions myself so that animations work
@ -154,6 +165,9 @@ class MainFragment : Fragment() {
return false
}
/**
* Handle the visibility of CompactPlaybackFragment. Done here so that there's a nice animation.
*/
private fun handleCompactPlaybackVisibility(binding: FragmentMainBinding, song: Song?) {
if (song == null) {
logD("Hiding CompactPlaybackFragment since no song is being played.")

View file

@ -30,6 +30,11 @@ class PlaybackStateDatabase(context: Context) :
// --- DATABASE CONSTRUCTION FUNCTIONS ---
/**
* Create a table
* @param database DB to create the tables on
* @param tableName The name of the table to create.
*/
private fun createTable(database: SQLiteDatabase, tableName: String) {
val command = StringBuilder()
command.append("CREATE TABLE IF NOT EXISTS $tableName(")
@ -43,6 +48,9 @@ class PlaybackStateDatabase(context: Context) :
database.execSQL(command.toString())
}
/**
* Construct a [PlaybackState] table
*/
private fun constructStateTable(command: StringBuilder): StringBuilder {
command.append("${PlaybackState.COLUMN_ID} LONG PRIMARY KEY,")
command.append("${PlaybackState.COLUMN_SONG_ID} LONG NOT NULL,")
@ -57,6 +65,9 @@ class PlaybackStateDatabase(context: Context) :
return command
}
/**
* Construct a [QueueItem] table
*/
private fun constructQueueTable(command: StringBuilder): StringBuilder {
command.append("${QueueItem.COLUMN_ID} LONG PRIMARY KEY,")
command.append("${QueueItem.COLUMN_SONG_ID} LONG NOT NULL,")

View file

@ -53,7 +53,7 @@ class AlbumDetailFragment : DetailFragment() {
doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) },
doOnLongClick = { data, view ->
PopupMenu(requireContext(), view).setupAlbumSongActions(
data, requireContext(), detailModel, playbackModel
requireContext(), data, detailModel, playbackModel
)
}
)

View file

@ -57,7 +57,7 @@ class ArtistDetailFragment : DetailFragment() {
},
doOnLongClick = { data, view ->
PopupMenu(requireContext(), view).setupAlbumActions(
data, requireContext(), playbackModel
requireContext(), data, playbackModel
)
}
)

View file

@ -38,10 +38,17 @@ class DetailViewModel : ViewModel() {
private val mNavToParent = MutableLiveData<Boolean>()
val navToParent: LiveData<Boolean> get() = mNavToParent
/**
* Update the current navigation status
* @param value Whether the current [DetailFragment] is navigating or not.
*/
fun updateNavigationStatus(value: Boolean) {
mIsNavigating = value
}
/**
* Increment the sort mode of the genre artists
*/
fun incrementGenreSortMode() {
mGenreSortMode.value = when (mGenreSortMode.value) {
SortMode.ALPHA_DOWN -> SortMode.ALPHA_UP
@ -51,6 +58,9 @@ class DetailViewModel : ViewModel() {
}
}
/**
* Increment the sort mode of the artist albums
*/
fun incrementArtistSortMode() {
mArtistSortMode.value = when (mArtistSortMode.value) {
SortMode.NUMERIC_DOWN -> SortMode.NUMERIC_UP
@ -62,6 +72,9 @@ class DetailViewModel : ViewModel() {
}
}
/**
* Increment the sort mode of the album songs
*/
fun incrementAlbumSortMode() {
mAlbumSortMode.value = when (mAlbumSortMode.value) {
SortMode.NUMERIC_DOWN -> SortMode.NUMERIC_UP
@ -83,10 +96,12 @@ class DetailViewModel : ViewModel() {
mCurrentAlbum.value = album
}
/** Mark that parent navigation should occur */
fun doNavToParent() {
mNavToParent.value = true
}
/** Mark that the UI is done with the parent navigation */
fun doneWithNavToParent() {
mNavToParent.value = false
}

View file

@ -55,12 +55,12 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
val binding = FragmentLibraryBinding.inflate(inflater)
val libraryAdapter = LibraryAdapter(
doOnClick = { navToItem(it) },
doOnClick = { onItemSelection(it) },
doOnLongClick = { data, view -> showActionsForItem(data, view) }
)
val searchAdapter = SearchAdapter(
doOnClick = { navToItem(it) },
doOnClick = { onItemSelection(it) },
doOnLongClick = { data, view -> showActionsForItem(data, view) }
)
@ -171,9 +171,9 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
libraryModel.updateNavigationStatus(false)
if (it is Song || it is Album) {
navToItem(playbackModel.song.value!!.album)
onItemSelection(playbackModel.song.value!!.album)
} else {
navToItem(it)
onItemSelection(it)
}
}
}
@ -197,12 +197,17 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
return true
}
/**
* Show the [PopupMenu] actions for an item.
* @param data The model that the actions should correspond to
* @param view The anchor view the menu should be bound to.
*/
private fun showActionsForItem(data: BaseModel, view: View) {
val menu = PopupMenu(requireContext(), view)
when (data) {
is Song -> menu.setupSongActions(data, requireContext(), playbackModel)
is Album -> menu.setupAlbumActions(data, requireContext(), playbackModel)
is Song -> menu.setupSongActions(requireContext(), data, playbackModel)
is Album -> menu.setupAlbumActions(requireContext(), data, playbackModel)
is Artist -> menu.setupArtistActions(data, playbackModel)
is Genre -> menu.setupGenreActions(data, playbackModel)
@ -211,7 +216,11 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
}
}
private fun navToItem(baseModel: BaseModel) {
/**
* Navigate to an item, or play it, depending on what the given item is.
* @param baseModel The data things should be done with
*/
private fun onItemSelection(baseModel: BaseModel) {
// If the item is a song [That was selected through search], then update the playback
// to that song instead of doing any navigation
if (baseModel is Song) {

View file

@ -22,24 +22,27 @@ import org.oxycblt.auxio.settings.SettingsManager
*/
class LibraryViewModel : ViewModel(), SettingsManager.Callback {
private val mSortMode = MutableLiveData(SortMode.ALPHA_DOWN)
val sortMode: LiveData<SortMode> get() = mSortMode
private val mLibraryData = MutableLiveData(listOf<BaseModel>())
val libraryData: LiveData<List<BaseModel>> get() = mLibraryData
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
val searchResults: LiveData<List<BaseModel>> get() = mSearchResults
private var mDisplayMode = DisplayMode.SHOW_ARTISTS
private var mIsNavigating = false
/** The current [SortMode] */
val sortMode: LiveData<SortMode> get() = mSortMode
/** The current library data */
val libraryData: LiveData<List<BaseModel>> get() = mLibraryData
/** The results from the last search query */
val searchResults: LiveData<List<BaseModel>> get() = mSearchResults
/** If LibraryFragment is already navigating */
val isNavigating: Boolean get() = mIsNavigating
private val settingsManager = SettingsManager.getInstance()
private val musicStore = MusicStore.getInstance()
init {
settingsManager.addCallback(this)
// Set up the display/sort modes
mDisplayMode = settingsManager.libraryDisplayMode
mSortMode.value = settingsManager.librarySortMode
@ -107,12 +110,19 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback {
}
}
/**
* Reset the search query.
*/
fun resetQuery() {
mSearchResults.value = listOf()
}
// --- LIBRARY FUNCTIONS ---
/**
* Update the current [SortMode].
* @param itemId The id of the menu item selected.
*/
fun updateSortMode(@IdRes itemId: Int) {
val mode = when (itemId) {
R.id.option_sort_none -> SortMode.NONE
@ -130,6 +140,10 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback {
}
}
/**
* Update the current navigation status
* @param value Whether LibraryFragment is navigating or not
*/
fun updateNavigationStatus(value: Boolean) {
mIsNavigating = value
}
@ -150,9 +164,12 @@ class LibraryViewModel : ViewModel(), SettingsManager.Callback {
// --- UTILS ---
/**
* Shortcut function for updating the library data with the current [SortMode]/[DisplayMode]
*/
private fun updateLibraryData() {
mLibraryData.value = mSortMode.value!!.getSortedBaseModelList(
musicStore.getListForShowMode(mDisplayMode)
musicStore.getListForDisplayMode(mDisplayMode)
)
}
}

View file

@ -62,6 +62,10 @@ class LibraryAdapter(
}
}
/**
* Update the data directly. [notifyDataSetChanged] will be called
* @param newData The new data to be used
*/
fun updateData(newData: List<BaseModel>) {
data = newData

View file

@ -18,7 +18,6 @@ import org.oxycblt.auxio.music.processing.MusicLoaderResponse
* @author OxygenCobalt
*/
class LoadingViewModel(private val app: Application) : ViewModel() {
// UI control
private val mResponse = MutableLiveData<MusicLoaderResponse>()
val response: LiveData<MusicLoaderResponse> get() = mResponse
@ -30,8 +29,10 @@ class LoadingViewModel(private val app: Application) : ViewModel() {
private var started = false
// Start the music loading sequence.
// This should only be ran once, use reload() for all other loads.
/**
* Start the music loading sequence.
* This should only be ran once, use reload() for all other loads.
*/
fun go() {
if (!started) {
started = true
@ -51,28 +52,39 @@ class LoadingViewModel(private val app: Application) : ViewModel() {
}
}
// UI communication functions
// LoadingFragment uses these so that button presses can update the ViewModel.
// all doneWithX functions are to reset the value so that LoadingFragment doesn't
// repeat commands if the view is recreated.
/**
* Reload the music
*/
fun reload() {
mRedo.value = true
doLoad()
}
/**
* Mark that the UI is done with the reload call
*/
fun doneWithReload() {
mRedo.value = false
}
/**
* Mark to start the grant process
*/
fun grant() {
mDoGrant.value = true
}
/**
* Mark that the UI is done with the grant process.
*/
fun doneWithGrant() {
mDoGrant.value = false
}
/**
* Factory for [LoadingViewModel] instances.
*/
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {

View file

@ -12,7 +12,8 @@ import org.oxycblt.auxio.recycler.DisplayMode
/**
* The main storage for music items. Use [MusicStore.getInstance] to get the single instance of it.
* TODO: Add a viewmodel so that UI elements aren't messing with the shared object.
* TODO: Completely rewrite this system.
* @author OxygenCobalt
*/
class MusicStore private constructor() {
private var mGenres = listOf<Genre>()
@ -27,6 +28,7 @@ class MusicStore private constructor() {
private var mSongs = listOf<Song>()
val songs: List<Song> get() = mSongs
/** All parent models loaded by Auxio */
val parents: MutableList<BaseModel> by lazy {
val parents = mutableListOf<BaseModel>()
parents.addAll(mGenres)
@ -92,7 +94,12 @@ class MusicStore private constructor() {
}
}
fun getListForShowMode(displayMode: DisplayMode): List<BaseModel> {
/**
* Get a list of data for a [DisplayMode].
* @param displayMode The [DisplayMode] given
* @return A list of [BaseModel]s for that [DisplayMode]
*/
fun getListForDisplayMode(displayMode: DisplayMode): List<BaseModel> {
return when (displayMode) {
DisplayMode.SHOW_GENRES -> mGenres
DisplayMode.SHOW_ARTISTS -> mArtists

View file

@ -9,8 +9,10 @@ import android.widget.TextView
import androidx.databinding.BindingAdapter
import org.oxycblt.auxio.R
// List of ID3 genres + Winamp extensions, each index corresponds to their int value.
// There are a lot more int-genre extensions as far as Im aware, but this works for most cases.
/**
* List of ID3 genres + Winamp extensions, each index corresponds to their int value.
* There are a lot more int-genre extensions as far as Im aware, but this works for most cases.
*/
private val ID3_GENRES = arrayOf(
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz",
"Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno",
@ -39,7 +41,10 @@ private const val PAREN_FILTER = "()"
// --- EXTENSION FUNCTIONS ---
// Convert legacy ID3 genres to a named genre
/**
* Convert legacy ID3 genres to their named genre
* @return The named genre for this legacy genre.
*/
fun String.toNamedGenre(): String? {
// Strip the genres of any parentheses, and convert it to an int
val intGenre = this.filterNot {
@ -51,7 +56,10 @@ fun String.toNamedGenre(): String? {
return ID3_GENRES.getOrNull(intGenre)
}
// Convert a song to its URI
/**
* Convert a song id to its URI
* @return The [Uri] for this song/
*/
fun Long.toURI(): Uri {
return ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
@ -59,7 +67,10 @@ fun Long.toURI(): Uri {
)
}
// Convert an albums ID into its album art URI
/**
* Get the URI for an album's cover art.
* @return The [Uri] for the album's cover art/
*/
fun Long.toAlbumArtURI(): Uri {
return ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"),
@ -67,7 +78,9 @@ fun Long.toAlbumArtURI(): Uri {
)
}
// Convert seconds into its string duration
/**
* Convert a [Long] of seconds into a string duration.
*/
fun Long.toDuration(): String {
var durationString = DateUtils.formatElapsedTime(this)
@ -79,7 +92,9 @@ fun Long.toDuration(): String {
return durationString
}
// Convert an integer to its formatted year
/**
* Convert an integer to its formatted year.
*/
fun Int.toYear(context: Context): String {
return if (this > 0) {
this.toString()
@ -87,8 +102,12 @@ fun Int.toYear(context: Context): String {
context.getString(R.string.placeholder_no_date)
}
}
// --- BINDING ADAPTERS ---
/**
* Bind the artist + album counts for a genre
*/
@BindingAdapter("genreCounts")
fun TextView.bindGenreCounts(genre: Genre) {
val artists = context.resources.getQuantityString(
@ -101,7 +120,9 @@ fun TextView.bindGenreCounts(genre: Genre) {
text = context.getString(R.string.format_double_counts, artists, albums)
}
// Get the artist genre.
/**
* Bind the most prominent artist genre
*/
// TODO: Add option to list all genres
@BindingAdapter("artistGenre")
fun TextView.bindArtistGenre(artist: Artist) {
@ -112,7 +133,9 @@ fun TextView.bindArtistGenre(artist: Artist) {
}
}
// Get the artist counts
/**
* Bind the album + song counts for an artist
*/
@BindingAdapter("artistCounts")
fun TextView.bindArtistCounts(artist: Artist) {
val albums = context.resources.getQuantityString(
@ -125,7 +148,9 @@ fun TextView.bindArtistCounts(artist: Artist) {
text = context.getString(R.string.format_double_counts, albums, songs)
}
// Get a bunch of miscellaneous album information [Year, Songs, Duration] and combine them
/**
* Get all album information, used on [org.oxycblt.auxio.detail.AlbumDetailFragment]
*/
@BindingAdapter("albumDetails")
fun TextView.bindAllAlbumDetails(album: Album) {
text = context.getString(
@ -139,7 +164,9 @@ fun TextView.bindAllAlbumDetails(album: Album) {
)
}
// Get *less* miscellaneous album information. Please don't confuse this with the above.
/**
* Get basic information about an album, used on album ViewHolders
*/
@BindingAdapter("albumInfo")
fun TextView.bindAlbumInfo(album: Album) {
text = context.getString(
@ -152,7 +179,9 @@ fun TextView.bindAlbumInfo(album: Album) {
)
}
// Bind the album year
/**
* Bind the year for an album.
*/
@BindingAdapter("albumYear")
fun TextView.bindAlbumYear(album: Album) {
text = album.year.toYear(context)

View file

@ -14,9 +14,14 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
// Get a bitmap for a song, onDone will be called when the bitmap is loaded.
// Don't use this on UI elements, that's what the BindingAdapters are for.
fun getBitmap(song: Song, context: Context, onDone: (Bitmap) -> Unit) {
/**
* Get a bitmap for a song. onDone will be called when the bitmap is loaded.
* **Do not use this on the UI elements, instead use the Binding Adapters.**
* @param context [Context] required
* @param song Song to load the cover for
* @param onDone What to do with the bitmap when the loading is finished.
*/
fun getBitmap(context: Context, song: Song, onDone: (Bitmap) -> Unit) {
Coil.enqueue(
ImageRequest.Builder(context)
.data(song.album.coverUri)
@ -28,7 +33,9 @@ fun getBitmap(song: Song, context: Context, onDone: (Bitmap) -> Unit) {
// --- BINDING ADAPTERS ---
// Get the cover art for a song
/**
* Bind the cover art for a song.
*/
@BindingAdapter("coverArt")
fun ImageView.bindCoverArt(song: Song) {
val request = getDefaultRequest(context, this)
@ -39,7 +46,9 @@ fun ImageView.bindCoverArt(song: Song) {
Coil.imageLoader(context).enqueue(request)
}
// Get the cover art for an album
/**
* Bind the cover art for an album
*/
@BindingAdapter("coverArt")
fun ImageView.bindCoverArt(album: Album) {
val request = getDefaultRequest(context, this)
@ -50,7 +59,9 @@ fun ImageView.bindCoverArt(album: Album) {
Coil.imageLoader(context).enqueue(request)
}
// Get the artist image
/**
* Bind the artist image for an artist.
*/
@BindingAdapter("artistImage")
fun ImageView.bindArtistImage(artist: Artist) {
val request: ImageRequest
@ -88,6 +99,9 @@ fun ImageView.bindArtistImage(artist: Artist) {
Coil.imageLoader(context).enqueue(request)
}
/**
* Bind the genre image for an artist.
*/
@BindingAdapter("genreImage")
fun ImageView.bindGenreImage(genre: Genre) {
val request: ImageRequest
@ -131,7 +145,10 @@ fun ImageView.bindGenreImage(genre: Genre) {
Coil.imageLoader(context).enqueue(request)
}
// Get the base request used across the other functions.
/**
* Get the base request used by the above functions
* @return The base request
*/
private fun getDefaultRequest(context: Context, imageView: ImageView): ImageRequest.Builder {
return ImageRequest.Builder(context)
.crossfade(true)

View file

@ -19,10 +19,9 @@ import okio.source
import org.oxycblt.auxio.R
import java.io.InputStream
const val MOSAIC_BITMAP_SIZE = 512
const val MOSAIC_BITMAP_INCREMENT = 256
// A Fetcher that takes multiple cover uris and turns them into a 2x2 mosaic image.
/**
* A [Fetcher] that takes multiple cover uris and turns them into a 2x2 mosaic image.
*/
class MosaicFetcher(private val context: Context) : Fetcher<List<Uri>> {
override suspend fun fetch(
pool: BitmapPool,
@ -108,4 +107,9 @@ class MosaicFetcher(private val context: Context) : Fetcher<List<Uri>> {
}
override fun key(data: List<Uri>): String = data.toString()
companion object {
private const val MOSAIC_BITMAP_SIZE = 512
private const val MOSAIC_BITMAP_INCREMENT = 256
}
}

View file

@ -16,13 +16,16 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toAlbumArtURI
import org.oxycblt.auxio.music.toNamedGenre
/** The response that [MusicLoader] gives when the process is done */
enum class MusicLoaderResponse {
DONE, FAILURE, NO_MUSIC
}
// Class that loads music from the FileSystem.
// TODO: Add custom artist images from the filesystem
// TODO: Move genre loading to songs [Loads would take longer though]
/**
* Object that loads music from the filesystem.
* TODO: Add custom artist images from the filesystem
* TODO: Move genre loading to songs [Loads would take longer though]
*/
class MusicLoader(
private val resolver: ContentResolver,

View file

@ -6,6 +6,9 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
/**
* The sorter object for music loading.
*/
class MusicSorter(
var genres: MutableList<Genre>,
val artists: MutableList<Artist>,

View file

@ -75,6 +75,8 @@ class CompactPlaybackFragment : Fragment() {
showAll(binding)
}
} else if (isLandscape) {
// CompactPlaybackFragment isn't fully hidden in landscape mode, only
// its UI elements are hidden.
hideAll(binding)
}
}
@ -126,6 +128,7 @@ class CompactPlaybackFragment : Fragment() {
/**
* Hide all UI elements, and disable the fragment from being clickable.
* Only called in landscape mode.
*/
private fun hideAll(binding: FragmentCompactPlaybackBinding) {
binding.apply {
@ -141,6 +144,7 @@ class CompactPlaybackFragment : Fragment() {
/**
* Unhide all UI elements, and make the fragment clickable.
* Only called in landscape mode.
*/
private fun showAll(binding: FragmentCompactPlaybackBinding) {
binding.apply {

View file

@ -106,7 +106,7 @@ fun NotificationCompat.Builder.setMetadata(
if (colorize) {
// getBitmap() is concurrent, so only call back to the object calling this function when
// the loading is over.
getBitmap(song, context) {
getBitmap(context, song) {
setLargeIcon(it)
onDone()
@ -165,6 +165,7 @@ fun NotificationCompat.Builder.updateMode(context: Context) {
private fun newAction(action: String, context: Context): NotificationCompat.Action {
val playbackManager = PlaybackStateManager.getInstance()
// Get the icon depending on the action & current state.
val drawable = when (action) {
NotificationUtils.ACTION_LOOP -> {
when (playbackManager.loopMode) {

View file

@ -201,9 +201,11 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
return binding.root
}
// Seeking callbacks
// --- SEEK CALLBACKS ---
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
// Only update the display if the change occured from a user
playbackModel.updatePositionDisplay(progress)
}
}
@ -215,6 +217,7 @@ class PlaybackFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) {
playbackModel.setSeekingStatus(false)
playbackModel.updatePosition(seekBar.progress)
// Confirm the position when seeking stops.
playbackModel.setPosition(seekBar.progress)
}
}

View file

@ -87,7 +87,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
return START_NOT_STICKY
}
// No binding, service is headless. Deliver updates through PlaybackStateManager instead.
// No binding, service is headless. Deliver updates through PlaybackStateManager/SettingsManager instead.
override fun onBind(intent: Intent): IBinder? = null
override fun onCreate() {
@ -150,7 +150,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
playbackManager.addCallback(this)
if (playbackManager.song != null) {
if (playbackManager.song != null || playbackManager.isRestored) {
restorePlayer()
restoreNotification()
}
@ -332,6 +332,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
// --- OTHER FUNCTIONS ---
/**
* Restore the [SimpleExoPlayer] state, if the service was destroyed while [PlaybackStateManager] persisted.
*/
private fun restorePlayer() {
playbackManager.song?.let {
val item = MediaItem.fromUri(it.id.toURI())
@ -350,6 +353,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Restore the notification, if the service was destroyed while [PlaybackStateManager] persisted.
*/
private fun restoreNotification() {
notification.updateExtraAction(this, settingsManager.useAltNotifAction)
notification.updateMode(this)
@ -366,6 +372,10 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Upload the song metadata to the [MediaSessionCompat], so that things such as album art
* show up on the lock screen.
*/
private fun uploadMetadataToSession(song: Song) {
val builder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.name)
@ -377,20 +387,23 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
getBitmap(song, this) {
getBitmap(this, song) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
mediaSession.setMetadata(builder.build())
}
}
private fun pollCurrentPosition() = flow {
while (player.isPlaying) {
emit(player.currentPosition)
delay(250)
}
}.conflate()
/**
* Start polling the position on a co-routine.
*/
private fun startPollingPosition() {
fun pollCurrentPosition() = flow {
while (player.isPlaying) {
emit(player.currentPosition)
delay(250)
}
}.conflate()
serviceScope.launch {
pollCurrentPosition().takeWhile { player.isPlaying }.collect {
playbackManager.setPosition(it)
@ -398,6 +411,10 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Bring the service into the foreground and show the notification, or refresh the notification.
* @param reason (Debug) The reason for this call.
*/
private fun startForegroundOrNotify(reason: String) {
// Don't start the foreground if the playback hasn't started yet AND if the playback hasn't
// been restored
@ -419,6 +436,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Stop the foreground state and hide the notification
*/
private fun stopForegroundAndNotification() {
stopForeground(true)
notificationManager.cancel(NotificationUtils.NOTIFICATION_ID)
@ -426,34 +446,41 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
isForeground = false
}
// Handle a media button event.
/**
* Handle a media button intent.
*/
private fun handleMediaButtonEvent(event: Intent): Boolean {
val item = event
.getParcelableExtra<Parcelable>(Intent.EXTRA_KEY_EVENT) as KeyEvent
if (item.action == KeyEvent.ACTION_DOWN) {
return when (item.keyCode) {
// Play/Pause if any of the keys are play/pause
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> {
playbackManager.setPlayingStatus(!playbackManager.isPlaying)
true
}
// Go to the next song is the key is next
KeyEvent.KEYCODE_MEDIA_NEXT -> {
playbackManager.next()
true
}
// Go to the previous song if the key is back
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playbackManager.prev()
true
}
// Rewind if the key is rewind
KeyEvent.KEYCODE_MEDIA_REWIND -> {
player.seekTo(0)
true
}
// Stop the service entirely if the key was stop/close
KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_CLOSE -> {
stopSelf()
true
@ -508,6 +535,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Resume, as long as its allowed.
*/
private fun resume() {
if (playbackManager.song != null && settingsManager.doPlugMgt) {
logD("Device connected, resuming...")
@ -516,6 +546,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Pause, as long as its allowed.
*/
private fun pause() {
if (playbackManager.song != null && settingsManager.doPlugMgt) {
logD("Device disconnected, pausing...")
@ -524,6 +557,9 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
}
/**
* Stop if the X button was clicked from the notification
*/
private fun stop() {
playbackManager.setPlayingStatus(false)
stopForegroundAndNotification()

View file

@ -28,55 +28,61 @@ import org.oxycblt.auxio.recycler.SortMode
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// Playback
private val mSong = MutableLiveData<Song?>()
val song: LiveData<Song?> get() = mSong
private val mParent = MutableLiveData<BaseModel?>()
val parent: LiveData<BaseModel?> get() = mParent
private val mPosition = MutableLiveData(0L)
val position: LiveData<Long> get() = mPosition
// Queue
private val mQueue = MutableLiveData(mutableListOf<Song>())
val queue: LiveData<MutableList<Song>> get() = mQueue
private val mUserQueue = MutableLiveData(mutableListOf<Song>())
val userQueue: LiveData<MutableList<Song>> get() = mUserQueue
private val mIndex = MutableLiveData(0)
// val index: LiveData<Int> get() = mIndex
private val mMode = MutableLiveData(PlaybackMode.ALL_SONGS)
val mode: LiveData<PlaybackMode> get() = mMode
// States
private val mIsPlaying = MutableLiveData(false)
val isPlaying: LiveData<Boolean> get() = mIsPlaying
private val mIsShuffling = MutableLiveData(false)
val isShuffling: LiveData<Boolean> get() = mIsShuffling
private val mLoopMode = MutableLiveData(LoopMode.NONE)
val loopMode: LiveData<LoopMode> get() = mLoopMode
// Other
private val mIsSeeking = MutableLiveData(false)
val isSeeking: LiveData<Boolean> get() = mIsSeeking
private val mNavToItem = MutableLiveData<BaseModel?>()
val navToItem: LiveData<BaseModel?> get() = mNavToItem
private var mCanAnimate = false
/** The current song. */
val song: LiveData<Song?> get() = mSong
/** The current model that is being played from, such as an [Album] or [Artist] */
val parent: LiveData<BaseModel?> get() = mParent
/** The current playback position, in seconds */
val position: LiveData<Long> get() = mPosition
/** The current queue determined my [mode] and [parent] */
val queue: LiveData<MutableList<Song>> get() = mQueue
/** The queue created by the user. */
val userQueue: LiveData<MutableList<Song>> get() = mUserQueue
/** The current [PlaybackMode] that also determines the queue */
val mode: LiveData<PlaybackMode> get() = mMode
/** Whether the playback is paused or played. */
val isPlaying: LiveData<Boolean> get() = mIsPlaying
/** Whether the queue is shuffled or not. */
val isShuffling: LiveData<Boolean> get() = mIsShuffling
/** The current [LoopMode] */
val loopMode: LiveData<LoopMode> get() = mLoopMode
/** Whether the user is seeking or not */
val isSeeking: LiveData<Boolean> get() = mIsSeeking
/** Whether to nav to an item or not */
val navToItem: LiveData<BaseModel?> get() = mNavToItem
/** Whether the play/pause button on CompactPlaybackFragment can animate */
val canAnimate: Boolean get() = mCanAnimate
/** The position as a duration string. */
val formattedPosition = Transformations.map(mPosition) {
it.toDuration()
}
/** The position as SeekBar progress. */
val positionAsProgress = Transformations.map(mPosition) {
if (mSong.value != null) it.toInt() else 0
}
/** The queue, without the previous items. */
val nextItemsInQueue = Transformations.map(mQueue) {
it.slice((mIndex.value!! + 1) until it.size)
}
@ -88,7 +94,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// If the PlaybackViewModel was cleared [Signified by PlaybackStateManager still being
// around & the fact that we are in the init function], then attempt to restore the
// viewmodel state. If it isn't, then wait for MainFragment to give the command to restore
// ViewModel state. If it isn't, then wait for MainFragment to give the command to restore
// PlaybackStateManager.
if (playbackManager.isRestored) {
restorePlaybackState()
@ -97,17 +103,16 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// --- PLAYING FUNCTIONS ---
// Play a song
/**
* Play a song.
* @param song The song to be played
* @param mode The [PlaybackMode] for it to be played in.
*/
fun playSong(song: Song, mode: PlaybackMode) {
playbackManager.playSong(song, mode)
}
// Play all songs
fun shuffleAll() {
playbackManager.shuffleAll()
}
// Play an album
/** Play an album.*/
fun playAlbum(album: Album, shuffled: Boolean) {
if (album.songs.isEmpty()) {
logE("Album is empty, Not playing.")
@ -118,7 +123,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.playParentModel(album, shuffled)
}
// Play an artist
/** Play an Artist */
fun playArtist(artist: Artist, shuffled: Boolean) {
if (artist.songs.isEmpty()) {
logE("Artist is empty, Not playing.")
@ -129,7 +134,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.playParentModel(artist, shuffled)
}
// Play a genre
/** Play a genre. */
fun playGenre(genre: Genre, shuffled: Boolean) {
if (genre.songs.isEmpty()) {
logE("Genre is empty, Not playing.")
@ -140,32 +145,44 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
playbackManager.playParentModel(genre, shuffled)
}
/** Shuffle all songs */
fun shuffleAll() {
playbackManager.shuffleAll()
}
// --- POSITION FUNCTIONS ---
// Update the position without pushing the change to playbackManager.
// This is used during seek events to give the user an idea of where they're seeking to.
/** Update the position and push it to [PlaybackStateManager] */
fun setPosition(progress: Int) {
playbackManager.seekTo((progress * 1000).toLong())
}
/**
* Update the position without pushing the change to [PlaybackStateManager].
* This is used during seek events to give the user an idea of where they're seeking to.
* @param progress The SeekBar progress to seek to.
*/
fun updatePositionDisplay(progress: Int) {
mPosition.value = progress.toLong()
}
// Update the position and push the change the playbackManager.
fun updatePosition(progress: Int) {
playbackManager.seekTo((progress * 1000).toLong())
}
// --- QUEUE FUNCTIONS ---
// Skip to next song.
/** Skip to the next song. */
fun skipNext() {
playbackManager.next()
}
// Skip to last song.
/** Skip to the previous song */
fun skipPrev() {
playbackManager.prev()
}
// Remove a queue OR user queue item, given a QueueAdapter index.
/**
* Remove a queue OR user queue item, given a QueueAdapter index.
* @param adapterIndex The [QueueAdapter] index to remove
* @param queueAdapter The [QueueAdapter] itself to push changes to when successful.
*/
fun removeQueueAdapterItem(adapterIndex: Int, queueAdapter: QueueAdapter) {
var index = adapterIndex.dec()
@ -188,7 +205,12 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
}
}
// Move queue OR user queue items, given QueueAdapter indices.
/**
* Move queue OR user queue items, given QueueAdapter indices.
* @param adapterFrom The [QueueAdapter] index that needs to be moved
* @param adapterTo The destination [QueueAdapter] index.
* @param queueAdapter the [QueueAdapter] to push changes to when successful.
*/
fun moveQueueAdapterItems(
adapterFrom: Int,
adapterTo: Int,
@ -233,44 +255,48 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
return true
}
/** Add a [Song] to the user queue.*/
fun addToUserQueue(song: Song) {
playbackManager.addToUserQueue(song)
}
/** Add an [Album] to the user queue */
fun addToUserQueue(album: Album) {
val songs = SortMode.NUMERIC_DOWN.getSortedSongList(album.songs)
playbackManager.addToUserQueue(songs)
}
/** Clear the user queue entirely */
fun clearUserQueue() {
playbackManager.clearUserQueue()
}
// --- STATUS FUNCTIONS ---
// Flip the playing status.
/** Flip the playing status, e.g from playing to paused */
fun invertPlayingStatus() {
enableAnimation()
playbackManager.setPlayingStatus(!playbackManager.isPlaying)
}
// Flip the shuffle status.
/** Flip the shuffle status, e.g from on to off */
fun invertShuffleStatus() {
playbackManager.setShuffleStatus(!playbackManager.isShuffling)
}
/** Increment the loop status, e.g from off to loop once */
fun incrementLoopStatus() {
playbackManager.setLoopMode(playbackManager.loopMode.increment())
}
// --- OTHER FUNCTIONS ---
fun setSeekingStatus(value: Boolean) {
mIsSeeking.value = value
}
// --- SAVE/RESTORE FUNCTIONS ---
/**
* Get [PlaybackStateManager] to restore its state from the database, if needed. Called by MainFragment.
* @param context [Context] required.
*/
fun restorePlaybackIfNeeded(context: Context) {
if (!playbackManager.isRestored) {
viewModelScope.launch {
@ -279,24 +305,55 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
}
}
/**
* Force save the current [PlaybackStateManager] state to the database. Called by SettingsListFragment.
* @param context [Context] required.
*/
fun save(context: Context) {
viewModelScope.launch {
playbackManager.saveStateToDatabase(context)
}
}
/** Attempt to restore the current playback state from an existing [PlaybackStateManager] instance */
private fun restorePlaybackState() {
logD("Attempting to restore playback state.")
mSong.value = playbackManager.song
mPosition.value = playbackManager.position / 1000
mParent.value = playbackManager.parent
mQueue.value = playbackManager.queue
mMode.value = playbackManager.mode
mUserQueue.value = playbackManager.userQueue
mIndex.value = playbackManager.index
mIsPlaying.value = playbackManager.isPlaying
mIsShuffling.value = playbackManager.isShuffling
mLoopMode.value = playbackManager.loopMode
}
// --- OTHER FUNCTIONS ---
/** Set whether the seeking indicator should be highlighted */
fun setSeekingStatus(value: Boolean) {
mIsSeeking.value = value
}
/** Navigate to an item, whether a song/album/artist */
fun navToItem(item: BaseModel) {
mNavToItem.value = item
}
/** Mark that the navigation process is done. */
fun doneWithNavToItem() {
mNavToItem.value = null
}
/** Enable animation on CompactPlaybackFragment */
fun enableAnimation() {
mCanAnimate = true
}
/** Disable animation on CompactPlaybackFragment */
fun disableAnimation() {
mCanAnimate = false
}
@ -348,19 +405,4 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
override fun onLoopUpdate(mode: LoopMode) {
mLoopMode.value = mode
}
private fun restorePlaybackState() {
logD("Attempting to restore playback state.")
mSong.value = playbackManager.song
mPosition.value = playbackManager.position / 1000
mParent.value = playbackManager.parent
mQueue.value = playbackManager.queue
mMode.value = playbackManager.mode
mUserQueue.value = playbackManager.userQueue
mIndex.value = playbackManager.index
mIsPlaying.value = playbackManager.isPlaying
mIsShuffling.value = playbackManager.isShuffling
mLoopMode.value = playbackManager.loopMode
}
}

View file

@ -22,10 +22,8 @@ import org.oxycblt.auxio.recycler.viewholders.HeaderViewHolder
/**
* The single adapter for both the Next Queue and the User Queue.
* - [submitList] is for the plain async diff calculations, use this if you
* have no idea what the differences are between the old data & the new data
* - [removeItem] and [moveItems] are used by [org.oxycblt.auxio.playback.PlaybackViewModel]
* so that this adapter doesn't flip-out when items are moved (Which happens with [AsyncListDiffer])
* @param touchHelper The [ItemTouchHelper] ***containing*** [QueueDragCallback] to be used
* @param playbackModel The [PlaybackViewModel] to dispatch updates to.
* @author OxygenCobalt
*/
class QueueAdapter(
@ -81,6 +79,9 @@ class QueueAdapter(
}
}
/**
* Submit data using [AsyncListDiffer]. **Only use this if you have no idea what changes occurred to the data**
*/
fun submitList(newData: MutableList<BaseModel>) {
if (data != newData) {
data = newData
@ -89,6 +90,9 @@ class QueueAdapter(
}
}
/**
* Move Items. Used since [submitList] will cause QueueAdapter to freak-out here.
*/
fun moveItems(adapterFrom: Int, adapterTo: Int) {
val item = data.removeAt(adapterFrom)
data.add(adapterTo, item)
@ -96,6 +100,9 @@ class QueueAdapter(
notifyItemMoved(adapterFrom, adapterTo)
}
/**
* Remove an item. Used since [submitList] will cause QueueAdapter to freak-out here.
*/
fun removeItem(adapterIndex: Int) {
data.removeAt(adapterIndex)
@ -121,7 +128,9 @@ class QueueAdapter(
}
}
// Generic ViewHolder for a queue item
/**
* Generic ViewHolder for a queue song
*/
inner class QueueSongViewHolder(
private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding, null, null) {
@ -146,6 +155,9 @@ class QueueAdapter(
}
}
/**
* Generic ViewHolder for the **user queue header**. Has the clear queue button.
*/
inner class UserQueueHeaderViewHolder(
context: Context,
private val binding: ItemActionHeaderBinding

View file

@ -11,6 +11,7 @@ import kotlin.math.sign
/**
* The Drag callback used by the queue recyclerview. Delivers updates to [PlaybackViewModel]
* and [QueueAdapter] simultaneously.
* @param playbackModel The [PlaybackViewModel] required to dispatch updates to.
* @author OxygenCobalt
*/
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
@ -68,6 +69,10 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
playbackModel.removeQueueAdapterItem(viewHolder.adapterPosition, queueAdapter)
}
/**
* Add the queue adapter to this callback.
* Done because there's a circular dependency between the two objects
*/
fun addQueueAdapter(adapter: QueueAdapter) {
queueAdapter = adapter
}

View file

@ -98,6 +98,10 @@ class QueueFragment : Fragment() {
return binding.root
}
/**
* Create the queue data that should be displayed
* @return The list of headers/songs that should be displayed.
*/
private fun createQueueData(): MutableList<BaseModel> {
val queue = mutableListOf<BaseModel>()

View file

@ -3,6 +3,9 @@ package org.oxycblt.auxio.playback.state
enum class LoopMode {
NONE, ONCE, INFINITE;
/**
* Increment the LoopMode, e.g from [NONE] to [ONCE]
*/
fun increment(): LoopMode {
return when (this) {
NONE -> ONCE
@ -11,6 +14,10 @@ enum class LoopMode {
}
}
/**
* Convert the LoopMode to an int constant that is saved in PlaybackStateDatabase
* @return The int constant for this mode
*/
fun toInt(): Int {
return when (this) {
NONE -> CONSTANT_NONE
@ -24,6 +31,10 @@ enum class LoopMode {
const val CONSTANT_ONCE = 0xA051
const val CONSTANT_INFINITE = 0xA052
/**
* Convert an int constant into a LoopMode
* @return The corresponding LoopMode. Null if it corresponds to nothing.
*/
fun fromInt(constant: Int): LoopMode? {
return when (constant) {
CONSTANT_NONE -> NONE

View file

@ -1,14 +1,20 @@
package org.oxycblt.auxio.playback.state
import java.lang.IllegalArgumentException
// Enum for instruction how the queue should function.
// ALL SONGS -> Play from all songs
// IN_ARTIST -> Play from the songs of the artist
// IN_ALBUM -> Play from the songs of the album
// Enum that instructs how the queue should be constructed
enum class PlaybackMode {
IN_ARTIST, IN_GENRE, IN_ALBUM, ALL_SONGS;
/** Construct the queue from the genre's songs */
IN_GENRE,
/** Construct the queue from the artist's songs */
IN_ARTIST,
/** Construct the queue from the album's songs */
IN_ALBUM,
/** Construct the queue from all songs */
ALL_SONGS;
/**
* Convert the mode into an int constant, to be saved in PlaybackStateDatabase
* @return The constant for this mode,
*/
fun toInt(): Int {
return when (this) {
IN_ARTIST -> CONSTANT_IN_ARTIST
@ -24,6 +30,10 @@ enum class PlaybackMode {
const val CONSTANT_IN_ALBUM = 0xA042
const val CONSTANT_ALL_SONGS = 0xA043
/**
* Get a [PlaybackMode] for an int constant
* @return The mode, null if there isnt one for this.
*/
fun fromInt(constant: Int): PlaybackMode? {
return when (constant) {
CONSTANT_IN_ARTIST -> IN_ARTIST
@ -35,6 +45,9 @@ enum class PlaybackMode {
}
}
/**
* Get the value of a [PlaybackMode] from a string. Returns [ALL_SONGS] as a fallback.
*/
fun valueOfOrFallback(value: String?): PlaybackMode {
if (value == null) {
return ALL_SONGS

View file

@ -24,7 +24,7 @@ import kotlin.random.Random
* - If you want to use the playback state with the ExoPlayer instance or system-side things,
* use [org.oxycblt.auxio.playback.PlaybackService].
*
* All instantiation should be done with [PlaybackStateManager.getInstance].
* All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt
*/
class PlaybackStateManager private constructor() {
@ -89,17 +89,29 @@ class PlaybackStateManager private constructor() {
private var mHasPlayed = false
private var mShuffleSeed = -1L
/** The currently playing song. Null if there isn't one */
val song: Song? get() = mSong
/** The parent the queue is based on, null if all_songs */
val parent: BaseModel? get() = mParent
/** The current playback progress */
val position: Long get() = mPosition
/** The current queue determined by [parent] and [mode] */
val queue: MutableList<Song> get() = mQueue
/** The queue created by the user. */
val userQueue: MutableList<Song> get() = mUserQueue
/** The current index of the queue */
val index: Int get() = mIndex
/** The current [PlaybackMode] */
val mode: PlaybackMode get() = mMode
/** Whether playback is paused or not */
val isPlaying: Boolean get() = mIsPlaying
/** Whether the queue is shuffled */
val isShuffling: Boolean get() = mIsShuffling
/** The current [LoopMode] */
val loopMode: LoopMode get() = mLoopMode
/** Whether this instance has already been restored */
val isRestored: Boolean get() = mIsRestored
/** Whether this instance has started playing or not */
val hasPlayed: Boolean get() = mHasPlayed
private val settingsManager = SettingsManager.getInstance()
@ -108,16 +120,28 @@ class PlaybackStateManager private constructor() {
private val callbacks = mutableListOf<Callback>()
/**
* Add a [PlaybackStateManager.Callback] to this instance.
* Make sure to remove the callback with [removeCallback] when done.
*/
fun addCallback(callback: Callback) {
callbacks.add(callback)
}
/**
* Remove a [PlaybackStateManager.Callback] bound to this instance.
*/
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
// --- PLAYING FUNCTIONS ---
/**
* Play a song.
* @param song The song to be played
* @param mode The [PlaybackMode] to construct the queue off of.
*/
fun playSong(song: Song, mode: PlaybackMode) {
// Auxio doesn't support playing songs while swapping the mode to GENRE, as its impossible
// to determine what genre a song has.
@ -169,9 +193,14 @@ class PlaybackStateManager private constructor() {
mIndex = mQueue.indexOf(song)
}
/**
* Play a parent model, e.g an artist or an album.
* @param baseModel The model to use
* @param shuffled Whether to shuffle the queue or not
*/
fun playParentModel(baseModel: BaseModel, shuffled: Boolean) {
// This should never occur.
if (baseModel is Song || baseModel is Header) {
// This should never occur.
logE("playParentModel does not support ${baseModel::class.simpleName}.")
return
@ -212,6 +241,11 @@ class PlaybackStateManager private constructor() {
}
}
/**
* Shortcut function for updating what song is being played. ***USE THIS INSTEAD OF WRITING OUT ALL THE CODE YOURSELF!!!***
* @param song The song to play
* @param dontPlay (Optional, defaults to false) whether to not set [isPlaying] to true.
*/
private fun updatePlayback(song: Song, dontPlay: Boolean = false) {
mSong = song
mPosition = 0
@ -223,15 +257,24 @@ class PlaybackStateManager private constructor() {
mIsInUserQueue = false
}
/**
* Update the current position. Will not notify any listeners of a seek event, that's what [seekTo] is for.
* @param position The new position in millis.
*/
fun setPosition(position: Long) {
// Don't accept any bugged positions that are over the duration of the song.
mSong?.let {
// Don't accept any bugged positions that are over the duration of the song.
if (position <= it.duration) {
mPosition = position
}
}
}
/**
* **Seek** to a position, this calls [PlaybackStateManager.Callback.onSeekConfirm] to notify
* elements that rely on that.
* @param position The position to seek to in millis.
*/
fun seekTo(position: Long) {
mPosition = position
@ -299,7 +342,7 @@ class PlaybackStateManager private constructor() {
*/
private fun handlePlaylistEnd() {
when (settingsManager.doAtEnd) {
SettingsManager.EntryNames.AT_END_LOOP_PAUSE -> {
SettingsManager.EntryValues.AT_END_LOOP_PAUSE -> {
mIndex = 0
forceQueueUpdate()
@ -307,14 +350,14 @@ class PlaybackStateManager private constructor() {
setPlayingStatus(false)
}
SettingsManager.EntryNames.AT_END_LOOP -> {
SettingsManager.EntryValues.AT_END_LOOP -> {
mIndex = 0
forceQueueUpdate()
updatePlayback(mQueue[0])
}
SettingsManager.EntryNames.AT_END_STOP -> {
SettingsManager.EntryValues.AT_END_STOP -> {
mQueue.clear()
forceQueueUpdate()
@ -325,6 +368,10 @@ class PlaybackStateManager private constructor() {
// --- QUEUE EDITING FUNCTIONS ---
/**
* Remove a queue item at a QUEUE index. Will log an error if the index is out of bounds
* @param index The index at which the item should be removed.
*/
fun removeQueueItem(index: Int): Boolean {
logD("Removing item ${mQueue[index].name}.")
@ -341,6 +388,12 @@ class PlaybackStateManager private constructor() {
return true
}
/**
* Move a queue item from a QUEUE INDEX to a QUEUE INDEX. Will log an error if one of the indices
* is out of bounds.
* @param from The starting item's index
* @param to The destination index.
*/
fun moveQueueItems(from: Int, to: Int): Boolean {
try {
val item = mQueue.removeAt(from)
@ -356,18 +409,30 @@ class PlaybackStateManager private constructor() {
return true
}
/**
* Add a song to the user queue.
* @param song The song to add
*/
fun addToUserQueue(song: Song) {
mUserQueue.add(song)
forceUserQueueUpdate()
}
/**
* Add a list of songs to the user queue.
* @param songs The songs to add.
*/
fun addToUserQueue(songs: List<Song>) {
mUserQueue.addAll(songs)
forceUserQueueUpdate()
}
/**
* Remove a USER QUEUE item at a USER QUEUE index. Will log an error if the index is out of bounds.
* @param index The index at which the item should be removed.
*/
fun removeUserQueueItem(index: Int) {
logD("Removing item ${mUserQueue[index].name}.")
@ -382,6 +447,12 @@ class PlaybackStateManager private constructor() {
forceUserQueueUpdate()
}
/**
* Move a USER QUEUE item from a USER QUEUE index to another USER QUEUE index. Will log an error if one of the indices
* is out of bounds.
* @param from The starting item's index
* @param to The destination index.
*/
fun moveUserQueueItems(from: Int, to: Int) {
try {
val item = mUserQueue.removeAt(from)
@ -395,23 +466,34 @@ class PlaybackStateManager private constructor() {
forceUserQueueUpdate()
}
/**
* Clear the user queue. Forces a user queue update.
*/
fun clearUserQueue() {
mUserQueue.clear()
forceUserQueueUpdate()
}
// Force any callbacks to update when the queue is changed.
/**
* Force any callbacks to receive a queue update.
*/
private fun forceQueueUpdate() {
mQueue = mQueue
}
/**
* Force any callbacks to recieve a user queue update.
*/
private fun forceUserQueueUpdate() {
mUserQueue = mUserQueue
}
// --- SHUFFLE FUNCTIONS ---
/**
* Shuffle all songs.
*/
fun shuffleAll() {
val musicStore = MusicStore.getInstance()
@ -424,13 +506,17 @@ class PlaybackStateManager private constructor() {
updatePlayback(mQueue[0])
}
// Generate a new shuffled queue.
private fun genShuffle(keepSong: Boolean) {
/**
* Generate a new shuffled queue.
* @param keepSong Whether to keep the currently playing song or to dispose of it
* @param useLastSong (Optional, defaults to false) Whether to use the previous song for the index calculations caused by the above parameter.
*/
private fun genShuffle(keepSong: Boolean, useLastSong: Boolean = false) {
val newSeed = Random.Default.nextLong()
logD("Shuffling queue with seed $newSeed")
val lastSong = if (mIsInUserQueue) mQueue[mIndex] else mSong
val lastSong = if (useLastSong) mQueue[mIndex] else mSong
mShuffleSeed = newSeed
@ -448,11 +534,14 @@ class PlaybackStateManager private constructor() {
forceQueueUpdate()
}
// Stop the queue and attempt to restore to the previous state
private fun resetShuffle() {
/**
* Reset the queue to its normal, ordered state.
* @param useLastSong (Optional, defaults to false) Whether to use the previous song for the index calculations.
*/
private fun resetShuffle(useLastSong: Boolean = false) {
mShuffleSeed = -1L
val lastSong = if (mIsInUserQueue) mQueue[mIndex] else mSong
val lastSong = if (useLastSong) mQueue[mIndex] else mSong
setupOrderedQueue()
@ -463,6 +552,10 @@ class PlaybackStateManager private constructor() {
// --- STATE FUNCTIONS ---
/**
* Set the current playing status
* @param value Whether the playback should be playing or paused.
*/
fun setPlayingStatus(value: Boolean) {
if (mIsPlaying != value) {
if (value) {
@ -473,24 +566,39 @@ class PlaybackStateManager private constructor() {
}
}
/**
* Set the shuffle status. Updates the queue accordingly
* @param value Whether the queue should be shuffled or not.
*/
fun setShuffleStatus(value: Boolean) {
mIsShuffling = value
if (mIsShuffling) {
genShuffle(true)
genShuffle(true, mIsInUserQueue)
} else {
resetShuffle()
resetShuffle(mIsInUserQueue)
}
}
/**
* Set the [LoopMode]
* @param value The [LoopMode] to be used
*/
fun setLoopMode(mode: LoopMode) {
mLoopMode = mode
}
/**
* Reset the has played status as if this instance is fresh.
*/
fun resetHasPlayedStatus() {
mHasPlayed = false
}
/**
* Reset the current [LoopMode], if needed.
* Use this instead of duplicating the code manually.
*/
private fun resetLoopMode() {
// Reset the loop mode from ONCE if needed.
if (mLoopMode == LoopMode.ONCE) {
@ -500,6 +608,10 @@ class PlaybackStateManager private constructor() {
// --- PERSISTENCE FUNCTIONS ---
/**
* Save the current state to the database.
* @param context [Context] required
*/
suspend fun saveStateToDatabase(context: Context) {
logD("Saving state to DB.")
@ -519,6 +631,10 @@ class PlaybackStateManager private constructor() {
logD("Save finished in ${time}ms")
}
/**
* Restore the state from the database
* @param context [Context] required.
*/
suspend fun getStateFromDatabase(context: Context) {
logD("Getting state from DB.")
@ -553,6 +669,10 @@ class PlaybackStateManager private constructor() {
mIsRestored = true
}
/**
* Back the current state into a [PlaybackState] to be saved.
* @return A [PlaybackState] reflecting the current state.
*/
private fun packToPlaybackState(): PlaybackState {
val songId = mSong?.id ?: -1L
val parentId = mParent?.id ?: -1L
@ -571,6 +691,10 @@ class PlaybackStateManager private constructor() {
)
}
/**
* Pack the queue into a list of [QueueItem]s to be saved.
* @return A list of packed queue items.
*/
private fun packQueue(): List<QueueItem> {
val unified = mutableListOf<QueueItem>()
@ -589,6 +713,10 @@ class PlaybackStateManager private constructor() {
return unified
}
/**
* Unpack the state from a [PlaybackState]
* @param playbackState The state to unpack.
*/
private fun unpackFromPlaybackState(playbackState: PlaybackState) {
val musicStore = MusicStore.getInstance()
@ -609,6 +737,10 @@ class PlaybackStateManager private constructor() {
}
}
/**
* Unpack a list of queue items into a queue & user queue.
* @param queueItems The list of [QueueItem]s to unpack.
*/
private fun unpackQueue(queueItems: List<QueueItem>) {
val musicStore = MusicStore.getInstance()
@ -637,6 +769,9 @@ class PlaybackStateManager private constructor() {
forceUserQueueUpdate()
}
/**
* Do the sanity check to make sure the parent was not lost in the restore process.
*/
private fun doParentSanityCheck() {
// Check if the parent was lost while in the DB.
if (mSong != null && mParent == null && mMode != PlaybackMode.ALL_SONGS) {
@ -695,6 +830,9 @@ class PlaybackStateManager private constructor() {
// --- ORDERING FUNCTIONS ---
/**
* Set up an ordered queue.
*/
private fun setupOrderedQueue() {
mQueue = when (mMode) {
PlaybackMode.IN_ARTIST -> orderSongsInArtist(mParent as Artist)
@ -704,10 +842,17 @@ class PlaybackStateManager private constructor() {
}
}
/**
* Create an ordered queue based on an [Album].
*/
private fun orderSongsInAlbum(album: Album): MutableList<Song> {
return album.songs.sortedBy { it.track }.toMutableList()
}
/**
* Create an ordered queue based on an [Artist].
* @return A list of the songs in the [Artist], ordered.
*/
private fun orderSongsInArtist(artist: Artist): MutableList<Song> {
val final = mutableListOf<Song>()
@ -718,6 +863,10 @@ class PlaybackStateManager private constructor() {
return final
}
/**
* Create an ordered queue based on a [Genre].
* @return A list of the songs in the [Genre], ordered.
*/
private fun orderSongsInGenre(genre: Genre): MutableList<Song> {
val final = mutableListOf<Song>()
@ -738,17 +887,29 @@ class PlaybackStateManager private constructor() {
* remove them on destruction with [removeCallback].
*/
interface Callback {
/** Called when the song updates */
fun onSongUpdate(song: Song?) {}
/** Called when the parent updates */
fun onParentUpdate(parent: BaseModel?) {}
/** Called when the position updates */
fun onPositionUpdate(position: Long) {}
/** Called when the queue updates */
fun onQueueUpdate(queue: MutableList<Song>) {}
/** Called when the user queue updates */
fun onUserQueueUpdate(userQueue: MutableList<Song>) {}
/** Called when the mode updates */
fun onModeUpdate(mode: PlaybackMode) {}
/** Called when the index updates */
fun onIndexUpdate(index: Int) {}
/** Called when the playing status changes */
fun onPlayingUpdate(isPlaying: Boolean) {}
/** Called when the shuffle status changes */
fun onShuffleUpdate(isShuffling: Boolean) {}
/** Called when the loop mode changes */
fun onLoopUpdate(mode: LoopMode) {}
/** Called when a seek is confirmed */
fun onSeekConfirm(position: Long) {}
/** Called when the restore process is finished */
fun onRestoreFinish() {}
}

View file

@ -2,7 +2,6 @@ package org.oxycblt.auxio.recycler
import androidx.annotation.DrawableRes
import org.oxycblt.auxio.R
import java.lang.IllegalArgumentException
/**
* An enum for determining what items to show in a given list.

View file

@ -55,6 +55,7 @@ class NoLeakThumbView @JvmOverloads constructor(
private val thumbAnimation: SpringAnimation
init {
// --- VIEW SETUP ---
LayoutInflater.from(context).inflate(
R.layout.fast_scroller_thumb_view, this, true
)
@ -63,10 +64,25 @@ class NoLeakThumbView @JvmOverloads constructor(
textView = thumbView.findViewById(R.id.fast_scroller_thumb_text)
iconView = thumbView.findViewById(R.id.fast_scroller_thumb_icon)
// --- UI SETUP ---
isActivated = false
isVisible = false
applyStyle()
thumbView.backgroundTintList = thumbColor
if (Build.VERSION.SDK_INT == 21) {
// Workaround for 21 background tint bug
(thumbView.background as GradientDrawable).apply {
mutate()
color = thumbColor
}
}
TextViewCompat.setTextAppearance(textView, textAppearanceRes)
textView.setTextColor(textColor)
iconView.imageTintList = ColorStateList.valueOf(iconColor)
thumbAnimation = SpringAnimation(thumbView, DynamicAnimation.TRANSLATION_Y).apply {
spring = SpringForce().apply {
@ -75,6 +91,9 @@ class NoLeakThumbView @JvmOverloads constructor(
}
}
/**
* Setup this view with a [FastScrollerView].
*/
@SuppressLint("ClickableViewAccessibility")
fun setupWithFastScroller(fastScrollerView: FastScrollerView) {
check(!isSetup) { "Only set this view's FastScrollerView once!" }
@ -126,29 +145,19 @@ class NoLeakThumbView @JvmOverloads constructor(
return consumed
}
private fun applyStyle() {
thumbView.backgroundTintList = thumbColor
if (Build.VERSION.SDK_INT == 21) {
// Workaround for 21 background tint bug
(thumbView.background as GradientDrawable).apply {
mutate()
color = thumbColor
}
}
TextViewCompat.setTextAppearance(textView, textAppearanceRes)
textView.setTextColor(textColor)
iconView.imageTintList = ColorStateList.valueOf(iconColor)
}
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) {
val thumbTargetY = indicatorCenterY.toFloat() - (thumbView.measuredHeight / 2)
thumbAnimation.animateToFinalPosition(thumbTargetY)
// Don't animate if the view is invisible.
if (!isActivated || !isVisible) {
y = thumbTargetY
} else {
thumbAnimation.animateToFinalPosition(thumbTargetY)
}
when (indicator) {
is FastScrollItemIndicator.Text -> {

View file

@ -1,6 +1,7 @@
package org.oxycblt.auxio.recycler
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -21,71 +22,96 @@ enum class SortMode(@DrawableRes val iconRes: Int) {
NUMERIC_UP(R.drawable.ic_sort_numeric_up),
NUMERIC_DOWN(R.drawable.ic_sort_numeric_down);
fun getSortedArtistList(list: List<Artist>): List<Artist> {
/**
* Get a sorted list of artists for a SortMode. Only supports alphabetic sorting.
* @param artists An unsorted list of artists.
* @return The sorted list of artists.
*/
fun getSortedArtistList(artists: List<Artist>): List<Artist> {
return when (this) {
ALPHA_UP -> list.sortedWith(
ALPHA_UP -> artists.sortedWith(
compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }
)
ALPHA_DOWN -> list.sortedWith(
ALPHA_DOWN -> artists.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
)
else -> list
else -> artists
}
}
fun getSortedAlbumList(list: List<Album>): List<Album> {
/**
* Get a sorted list of albums for a SortMode. Supports alpha + numeric sorting.
* @param albums An unsorted list of albums.
* @return The sorted list of albums.
*/
fun getSortedAlbumList(albums: List<Album>): List<Album> {
return when (this) {
ALPHA_UP -> list.sortedWith(
ALPHA_UP -> albums.sortedWith(
compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }
)
ALPHA_DOWN -> list.sortedWith(
ALPHA_DOWN -> albums.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
)
NUMERIC_UP -> list.sortedBy { it.year }
NUMERIC_DOWN -> list.sortedByDescending { it.year }
NUMERIC_UP -> albums.sortedBy { it.year }
NUMERIC_DOWN -> albums.sortedByDescending { it.year }
else -> list
else -> albums
}
}
fun getSortedSongList(list: List<Song>): List<Song> {
/**
* Get a sorted list of songs for a SortMode. Supports alpha + numeric sorting.
* @param songs An unsorted list of songs.
* @return The sorted list of songs.
*/
fun getSortedSongList(songs: List<Song>): List<Song> {
return when (this) {
ALPHA_UP -> list.sortedWith(
ALPHA_UP -> songs.sortedWith(
compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }
)
ALPHA_DOWN -> list.sortedWith(
ALPHA_DOWN -> songs.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
)
NUMERIC_UP -> list.sortedWith(compareByDescending { it.track })
NUMERIC_DOWN -> list.sortedWith(compareBy { it.track })
NUMERIC_UP -> songs.sortedWith(compareByDescending { it.track })
NUMERIC_DOWN -> songs.sortedWith(compareBy { it.track })
else -> list
else -> songs
}
}
fun getSortedBaseModelList(list: List<BaseModel>): List<BaseModel> {
/**
* Get a sorted list of BaseModels. Supports alpha + numeric sorting.
* @param baseModels An unsorted list of BaseModels.
* @return The sorted list of BaseModels.
*/
fun getSortedBaseModelList(baseModels: List<BaseModel>): List<BaseModel> {
return when (this) {
ALPHA_UP -> list.sortedWith(
ALPHA_UP -> baseModels.sortedWith(
compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }
)
ALPHA_DOWN -> list.sortedWith(
ALPHA_DOWN -> baseModels.sortedWith(
compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }
)
NUMERIC_UP -> list.sortedWith(compareByDescending { it.id })
NUMERIC_DOWN -> list.sortedWith(compareBy { it.id })
NUMERIC_UP -> baseModels.sortedWith(compareByDescending { it.id })
NUMERIC_DOWN -> baseModels.sortedWith(compareBy { it.id })
else -> list
else -> baseModels
}
}
/**
* Get a sorting menu ID for this mode. Alphabetic only.
* @return The action id for this mode.
*/
@IdRes
fun toMenuId(): Int {
return when (this) {
NONE -> R.id.option_sort_none
@ -96,6 +122,10 @@ enum class SortMode(@DrawableRes val iconRes: Int) {
}
}
/**
* Get the constant for this mode. Used to write a compressed variant to SettingsManager
* @return The int constant for this mode.
*/
fun toInt(): Int {
return when (this) {
NONE -> CONSTANT_NONE
@ -113,6 +143,10 @@ enum class SortMode(@DrawableRes val iconRes: Int) {
const val CONSTANT_NUMERIC_UP = 0xA063
const val CONSTANT_NUMERIC_DOWN = 0xA065
/**
* Get an enum for an int constant
* @return The [SortMode] if the constant is valid, null otherwise.
*/
fun fromInt(value: Int): SortMode? {
return when (value) {
CONSTANT_NONE -> NONE

View file

@ -7,6 +7,7 @@ import org.oxycblt.auxio.music.BaseModel
/**
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders.
* @param T The datatype, inheriting [BaseModel] for this ViewHolder.
* @property baseBinding Basic [ViewDataBinding] required to set up click listeners & sizing.
* @property doOnClick Function that specifies what to do when an item is clicked. Specify null if you want no action to occur.
* @property doOnLongClick Function that specifies what to do when an item is long clicked. Specify null if you want no action to occur.

View file

@ -13,10 +13,15 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.viewholders.AlbumViewHolder.Companion.from
import org.oxycblt.auxio.recycler.viewholders.ArtistViewHolder.Companion.from
import org.oxycblt.auxio.recycler.viewholders.GenreViewHolder.Companion.from
import org.oxycblt.auxio.recycler.viewholders.HeaderViewHolder.Companion.from
import org.oxycblt.auxio.recycler.viewholders.SongViewHolder.Companion.from
// Shared ViewHolders for each ViewModel, providing basic information
// All new instances should be created with from() instead of direct instantiation.
/**
* The Shared ViewHolder for a [Genre]. Instantiation should be done with [from].
*/
class GenreViewHolder private constructor(
private val binding: ItemGenreBinding,
doOnClick: (Genre) -> Unit,
@ -31,6 +36,9 @@ class GenreViewHolder private constructor(
companion object {
const val ITEM_TYPE = 0xA010
/**
* Create an instance of [GenreViewHolder]
*/
fun from(
context: Context,
doOnClick: (Genre) -> Unit,
@ -44,6 +52,9 @@ class GenreViewHolder private constructor(
}
}
/**
* The Shared ViewHolder for a [Artist]. Instantiation should be done with [from].
*/
class ArtistViewHolder private constructor(
private val binding: ItemArtistBinding,
doOnClick: (Artist) -> Unit,
@ -58,6 +69,9 @@ class ArtistViewHolder private constructor(
companion object {
const val ITEM_TYPE = 0xA011
/**
* Create an instance of [ArtistViewHolder]
*/
fun from(
context: Context,
doOnClick: (Artist) -> Unit,
@ -71,6 +85,9 @@ class ArtistViewHolder private constructor(
}
}
/**
* The Shared ViewHolder for a [Album]. Instantiation should be done with [from].
*/
class AlbumViewHolder private constructor(
private val binding: ItemAlbumBinding,
doOnClick: (data: Album) -> Unit,
@ -85,6 +102,9 @@ class AlbumViewHolder private constructor(
companion object {
const val ITEM_TYPE = 0xA012
/**
* Create an instance of [AlbumViewHolder]
*/
fun from(
context: Context,
doOnClick: (data: Album) -> Unit,
@ -98,6 +118,9 @@ class AlbumViewHolder private constructor(
}
}
/**
* The Shared ViewHolder for a [Song]. Instantiation should be done with [from].
*/
class SongViewHolder private constructor(
private val binding: ItemSongBinding,
doOnClick: (data: Song) -> Unit,
@ -114,6 +137,9 @@ class SongViewHolder private constructor(
companion object {
const val ITEM_TYPE = 0xA013
/**
* Create an instance of [SongViewHolder]
*/
fun from(
context: Context,
doOnClick: (data: Song) -> Unit,
@ -127,6 +153,9 @@ class SongViewHolder private constructor(
}
}
/**
* The Shared ViewHolder for a [Header]. Instantiation should be done with [from]
*/
class HeaderViewHolder(
private val binding: ItemHeaderBinding
) : BaseViewHolder<Header>(binding, null, null) {
@ -138,6 +167,9 @@ class HeaderViewHolder(
companion object {
const val ITEM_TYPE = 0xA014
/**
* Create an instance of [HeaderViewHolder]
*/
fun from(context: Context): HeaderViewHolder {
return HeaderViewHolder(
ItemHeaderBinding.inflate(LayoutInflater.from(context))

View file

@ -7,12 +7,13 @@ import org.oxycblt.auxio.R
/**
* Convert a string representing a theme entry name to an actual theme int that can be used.
* This is only done because PreferenceFragment does not like int arrays for some...reason.
* @return The proper theme int for this value.
*/
fun String.toThemeInt(): Int {
return when (this) {
SettingsManager.EntryNames.THEME_AUTO -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
SettingsManager.EntryNames.THEME_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
SettingsManager.EntryNames.THEME_DARK -> AppCompatDelegate.MODE_NIGHT_YES
SettingsManager.EntryValues.THEME_AUTO -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
SettingsManager.EntryValues.THEME_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
SettingsManager.EntryValues.THEME_DARK -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
@ -20,6 +21,7 @@ fun String.toThemeInt(): Int {
/**
* Convert an theme integer into an icon that can be used.
* @return An icon for this theme.
*/
@DrawableRes
fun Int.toThemeIcon(): Int {

View file

@ -9,6 +9,10 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSettingsBinding
import org.oxycblt.auxio.settings.ui.AboutDialog
/**
* A container [Fragment] for the settings menu.
* @author OxygenCobalt
*/
class SettingsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,

View file

@ -23,6 +23,10 @@ import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.createToast
import org.oxycblt.auxio.ui.getDetailedAccentSummary
/**
* The actual fragment containing the settings menu. Inherits [PreferenceFragmentCompat].
* @author OxygenCobalt
*/
@Suppress("UNUSED")
class SettingsListFragment : PreferenceFragmentCompat() {
private val playbackModel: PlaybackViewModel by activityViewModels()
@ -43,6 +47,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
private fun recursivelyHandleChildren(pref: Preference) {
if (pref is PreferenceCategory) {
// Show the debug category if this build is a debug build
if (pref.title == getString(R.string.debug_title) && BuildConfig.DEBUG) {
logD("Showing debug category.")

View file

@ -22,9 +22,15 @@ class SettingsManager private constructor(context: Context) :
// --- VALUES ---
/**
* The current theme.
*/
val theme: Int
get() = sharedPrefs.getString(Keys.KEY_THEME, EntryNames.THEME_AUTO)!!.toThemeInt()
get() = sharedPrefs.getString(Keys.KEY_THEME, EntryValues.THEME_AUTO)!!.toThemeInt()
/**
* The current accent.
*/
var accent: Pair<Int, Int>
get() {
val accentIndex = sharedPrefs.getInt(Keys.KEY_ACCENT, 5)
@ -42,12 +48,22 @@ class SettingsManager private constructor(context: Context) :
.apply()
}
/**
* Whether to colorize the notification
*/
val colorizeNotif: Boolean
get() = sharedPrefs.getBoolean(Keys.KEY_COLORIZE_NOTIFICATION, true)
/**
* Whether to display the LoopMode or the shuffle status on the notification.
* False if loop, true if shuffle.
*/
val useAltNotifAction: Boolean
get() = sharedPrefs.getBoolean(Keys.KEY_USE_ALT_NOTIFICATION_ACTION, false)
/**
* What to display on the library.
*/
val libraryDisplayMode: DisplayMode
get() = DisplayMode.valueOfOrFallback(
sharedPrefs.getString(
@ -56,12 +72,21 @@ class SettingsManager private constructor(context: Context) :
)
)
/**
* Whether to do Audio focus.
*/
val doAudioFocus: Boolean
get() = sharedPrefs.getBoolean(Keys.KEY_AUDIO_FOCUS, true)
/**
* Whether to resume/stop playback when a headset is connected/disconnected.
*/
val doPlugMgt: Boolean
get() = sharedPrefs.getBoolean(Keys.KEY_PLUG_MANAGEMENT, true)
/**
* What queue to create when a song is selected (ex. From All Songs or Search)
*/
val songPlaybackMode: PlaybackMode
get() = PlaybackMode.valueOfOrFallback(
sharedPrefs.getString(
@ -70,19 +95,34 @@ class SettingsManager private constructor(context: Context) :
)
)
/**
* What to do at the end of a playlist.
*/
val doAtEnd: String
get() = sharedPrefs.getString(Keys.KEY_AT_END, EntryNames.AT_END_LOOP_PAUSE)
?: EntryNames.AT_END_LOOP_PAUSE
get() = sharedPrefs.getString(Keys.KEY_AT_END, EntryValues.AT_END_LOOP_PAUSE)
?: EntryValues.AT_END_LOOP_PAUSE
/**
* Whether shuffle should stay on when a new song is selected.
*/
val keepShuffle: Boolean
get() = sharedPrefs.getBoolean(Keys.KEY_KEEP_SHUFFLE, false)
/**
* Whether to rewind when the back button is pressed.
*/
val rewindWithPrev: Boolean
get() = sharedPrefs.getBoolean(Keys.KEY_PREV_REWIND, true)
/**
* The threshold at which to rewind when the back button is pressed.
*/
val rewindThreshold: Long
get() = (sharedPrefs.getInt(Keys.KEY_REWIND_THRESHOLD, 5) * 1000).toLong()
/**
* The current [SortMode] of the library.
*/
var librarySortMode: SortMode
get() = SortMode.fromInt(
sharedPrefs.getInt(
@ -160,6 +200,9 @@ class SettingsManager private constructor(context: Context) :
}
}
/**
* SharedPreferences keys.
*/
object Keys {
const val KEY_THEME = "KEY_THEME"
const val KEY_ACCENT = "KEY_ACCENT"
@ -179,18 +222,32 @@ class SettingsManager private constructor(context: Context) :
const val KEY_DEBUG_SAVE = "KEY_SAVE_STATE"
}
object EntryNames {
/**
* Values for some settings entries that cant be enums/ints.
*/
object EntryValues {
const val THEME_AUTO = "AUTO"
const val THEME_LIGHT = "LIGHT"
const val THEME_DARK = "DARK"
/**
* Pause and loop at the end. Similar to Spotify.
*/
const val AT_END_LOOP_PAUSE = "LOOP_PAUSE"
/**
* Loop at the end. Similar to Music Player GO.
*/
const val AT_END_LOOP = "LOOP"
/**
* Stop at the end. Similar to phonograph.
*/
const val AT_END_STOP = "STOP"
}
/**
* An safe interface for receiving some preference updates. Use this instead of
* An safe interface for receiving some preference updates. Use/Extend this instead of
* [SharedPreferences.OnSharedPreferenceChangeListener] if possible, as it doesn't require a
* context.
*/

View file

@ -19,8 +19,11 @@ import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.ui.createToast
import org.oxycblt.auxio.ui.isLandscape
import java.lang.Exception
/**
* A [BottomSheetDialogFragment] that shows Auxio's about screen.
* @author OxygenCobalt
*/
class AboutDialog : BottomSheetDialogFragment() {
override fun getTheme() = R.style.Theme_BottomSheetFix
@ -59,6 +62,9 @@ class AboutDialog : BottomSheetDialogFragment() {
return binding.root
}
/**
* Go through the process of opening one of the about links in a browser.
*/
private fun openLinkInBrowser(link: String) {
check(link in LINKS) { "Invalid link." }

View file

@ -11,6 +11,11 @@ import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.getAccentItemSummary
import org.oxycblt.auxio.ui.toColor
/**
* An adapter that displays the list of all possible accents, and highlights the current one.
* @author OxygenCobalt
* @param doOnAccentConfirm What to do when an accent is confirmed.
*/
class AccentAdapter(
private val doOnAccentConfirm: (accent: Pair<Int, Int>) -> Unit
) : RecyclerView.Adapter<AccentAdapter.ViewHolder>() {

View file

@ -8,6 +8,12 @@ import org.oxycblt.auxio.databinding.ItemBasicSongBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
/**
* The adapter for [SongsFragment], shows basic songs without durations.
* @param data List of [Song]s to be shown
* @param doOnClick What to do on a click action
* @param doOnLongClick What to do on a long click action
*/
class SongsAdapter(
private val data: List<Song>,
private val doOnClick: (data: Song) -> Unit,

View file

@ -29,6 +29,8 @@ import kotlin.math.ceil
*/
class SongsFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
// Lazy init the text size so that it doesn't have to be calculated every time.
private val indicatorTextSize: Float by lazy {
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, 14F,
@ -51,7 +53,7 @@ class SongsFragment : Fragment() {
doOnClick = { playbackModel.playSong(it, settingsManager.songPlaybackMode) },
doOnLongClick = { data, view ->
PopupMenu(requireContext(), view).setupSongActions(
data, requireContext(), playbackModel
requireContext(), data, playbackModel
)
}
)
@ -93,6 +95,10 @@ class SongsFragment : Fragment() {
return binding.root
}
/**
* Go through the fast scroller process.
* @param binding Binding required
*/
private fun setupFastScroller(binding: FragmentSongsBinding) {
val musicStore = MusicStore.getInstance()

View file

@ -6,15 +6,10 @@ package org.oxycblt.auxio.ui
import android.annotation.TargetApi
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.graphics.Point
import android.os.Build
import android.util.DisplayMetrics
import android.view.View
import android.view.Window
import android.view.WindowInsetsController
import android.view.WindowManager
import org.oxycblt.auxio.logD
/**
* Check if we are in the "Irregular" landscape mode [e.g landscape, but nav bar is on the sides]
@ -27,6 +22,10 @@ fun Activity.isIrregularLandscape(): Boolean {
!isSystemBarOnBottom(this)
}
/**
* Check if edge is on. Really a glorified version check.
* @return Whether edge is on.
*/
fun isEdgeOn(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1
}

View file

@ -9,7 +9,6 @@ import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
import androidx.viewbinding.ViewBinding
import java.lang.IllegalStateException
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

View file

@ -51,6 +51,7 @@ fun ImageButton.disable(context: Context) {
/**
* Determine if the device is currently in landscape.
* @param resources [Resources] required
*/
fun isLandscape(resources: Resources): Boolean {
return resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
@ -76,8 +77,11 @@ fun Spanned.render(): Spanned {
/**
* Show actions for a song item, such as the ones found in [org.oxycblt.auxio.songs.SongsFragment]
* @param context [Context] required
* @param song [Song] The menu should correspond to
* @param playbackModel The [PlaybackViewModel] the menu should dispatch actions to.
*/
fun PopupMenu.setupSongActions(song: Song, context: Context, playbackModel: PlaybackViewModel) {
fun PopupMenu.setupSongActions(context: Context, song: Song, playbackModel: PlaybackViewModel) {
inflateAndShow(R.menu.menu_song_actions)
setOnMenuItemClickListener {
@ -124,10 +128,14 @@ fun PopupMenu.setupSongActions(song: Song, context: Context, playbackModel: Play
/**
* Show actions for a album song item, such as the ones found in
* [org.oxycblt.auxio.detail.AlbumDetailFragment]
* @param context [Context] required
* @param song [Song] The menu should correspond to
* @param detailModel The [DetailViewModel] the menu should dispatch some actions to.
* @param playbackModel The [PlaybackViewModel] the menu should dispatch actions to.
*/
fun PopupMenu.setupAlbumSongActions(
song: Song,
context: Context,
song: Song,
detailModel: DetailViewModel,
playbackModel: PlaybackViewModel
) {
@ -157,11 +165,14 @@ fun PopupMenu.setupAlbumSongActions(
}
/**
* Show actions for an [Album]
* Show actions for an [Album].
* @param context [Context] required
* @param album [Album] The menu should correspond to
* @param playbackModel The [PlaybackViewModel] the menu should dispatch actions to.
*/
fun PopupMenu.setupAlbumActions(
album: Album,
context: Context,
album: Album,
playbackModel: PlaybackViewModel
) {
setOnMenuItemClickListener {
@ -190,12 +201,11 @@ fun PopupMenu.setupAlbumActions(
}
/**
* Show actions for an [Artist]
* Show actions for an [Artist].
* @param artist The [Artist] The menu should correspond to
* @param playbackModel The [PlaybackViewModel] the menu should dispatch actions to.
*/
fun PopupMenu.setupArtistActions(
artist: Artist,
playbackModel: PlaybackViewModel
) {
fun PopupMenu.setupArtistActions(artist: Artist, playbackModel: PlaybackViewModel) {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_play -> {
@ -215,12 +225,11 @@ fun PopupMenu.setupArtistActions(
}
/**
* Show actions for a [Genre]
* Show actions for a [Genre].
* @param genre The [Genre] The menu should correspond to
* @param playbackModel The [PlaybackViewModel] the menu should dispatch actions to.
*/
fun PopupMenu.setupGenreActions(
genre: Genre,
playbackModel: PlaybackViewModel
) {
fun PopupMenu.setupGenreActions(genre: Genre, playbackModel: PlaybackViewModel) {
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_play -> {
@ -241,6 +250,7 @@ fun PopupMenu.setupGenreActions(
/**
* Shortcut method that inflates a menu and shows the action menu.
* @param menuRes the menu that should be shown.
*/
private fun PopupMenu.inflateAndShow(@MenuRes menuRes: Int) {
inflate(menuRes)

View file

@ -121,6 +121,9 @@ fun resolveAttr(context: Context, @AttrRes attr: Int): Int {
/**
* Get the name of an accent.
* @param context [Context] required
* @param newAccent The accent the name should be given for.
* @return The accent name according to the strings for this specific locale.
*/
fun getAccentItemSummary(context: Context, newAccent: Pair<Int, Int>): String {
val accentIndex = ACCENTS.indexOf(newAccent)
@ -131,7 +134,10 @@ fun getAccentItemSummary(context: Context, newAccent: Pair<Int, Int>): String {
}
/**
* Get the name (in bold) and the hex value of a theme.
* Get the name (in bold) and the hex value of a accent.
* @param context [Context] required
* @param newAccent Accent to get the information for
* @return A rendered span with the name in bold + the hex value of the accent.
*/
fun getDetailedAccentSummary(context: Context, newAccent: Pair<Int, Int>): Spanned {
val name = getAccentItemSummary(context, newAccent)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 398 KiB

View file

@ -2,17 +2,17 @@
I primarily built Auxio as a response to many other music players on android that did far too much, had frustrating UI/UX flaws, or both.
Since I want to keep this app from suffering the same fate as those others, I have baseline requirements for all contributions.
Since I want to keep this app from suffering the same fate as those others, Any requests/additions have to be accepted my **me** (OxygenCobalt) before I implement them or merge them.
All guidelines from the [Contribution Guidelines](../.github/CONTRIBUTING.md) still apply.
## Bug Fixes, Optimizations, Library Updates, Formatting, etc.
These will likely be accepted/add as long as they do not cause too much harm to the app's architecture.
These will likely be accepted/add as long as they do not cause too much harm to the app's architecture or UX.
## New Options/Customizations
These will be accepted/added if I see the value in the addition of those options and if they don't cause too much harm to the app's architecture. While I do want Auxio to remain consistent behavior/UI-wise, new options for certain customizations are still welcome.
These will be accepted/added if I see the value in the addition of those options and if they don't cause harm to the app's design. Most new options are fine as long as they dont degrade the UI/UX.
**Note:** I will be adding Black Mode/Custom Accents in the future. Read the [FAQ](FAQ.md) for more information.
@ -20,8 +20,8 @@ These will be accepted/added if I see the value in the addition of those options
These are far less likely to be accepted/added. As I said, I want to avoid Auxio from becoming overly bloated with features I do not use, and therefore **I will only accept features/UI changes that directly benefit my own usage.** If they do not, then I will reject/ignore them.
Feel free to fork Auxio to add your own features however.
Feel free to fork Auxio to add your own features however.
## A Final Note
I am an extremely busy student that only programs in his free time. If I decided to implement/accept an idea, it will take some time. **Be Patient.**
I am an extremely busy student that only programs in their free time. I also want to do things that aren't Android Dev. As a result, any additions I say I will add may take awhile. **Be patient**.

View file

@ -18,7 +18,7 @@ I still need to set up Weblate, but you can open a [Pull Request](https://github
## Why ExoPlayer?
ExoPlayer is far more flexible than the native MediaPlayer API, which allows Auxio to have consistent behavior across devices & OEMs, and also allowing the app to be extended to sources beyond local music files.
ExoPlayer is far more flexible than the native MediaPlayer API, which allows Auxio to have consistent behavior across devices & OEMs, along with allowing Auxio to be extended to music sources outside of local files. You can read more about the benefits (and drawbacks) of ExoPlayer [Here](https://exoplayer.dev/pros-and-cons.html).
## Why is there no black mode?
@ -40,10 +40,14 @@ The APIs for changing system bar colors were only added in API Level 27 (Oreo MR
I could possibly extend edge-to-edge support to those versions, but it would take awhile.
## Why doesnt edge-to-edge work when my phone is in landscape?
The way insets work when a *phone* (Not a tablet) is in landscape mode is somewhat broken, making it extremely hard (if not impossible) to get edge-to-edge working. Therefore its mostly disabled.
## How can I contribute/report issues?
Open an [Issue](https://github.com/OxygenCobalt/Auxio/issues) or a [Pull Request](https://github.com/OxygenCobalt/Auxio/pulls), please note the [Contribution Guidelines](../.github/CONTRIBUTING.md) and [Accepted Additions](ADDITIONS.md).
## Does this app keep/send any information about myself or my device.
## Does this app keep/send any information about myself or my device?
Never. There's no need. Auxio can't even access the internet.
Auxio does not log any information about the device or its owner, and it has no internet access to send that information off in the first place.