playback: redocument

Redocument the playback module.

This finally completes the re-documentation of this project.
This commit is contained in:
Alexander Capehart 2022-12-26 15:15:27 -07:00
parent 8fa1c92047
commit 0737dbace3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
42 changed files with 1334 additions and 1008 deletions

View file

@ -87,7 +87,7 @@ import java.util.Map;
*/
public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
/** Callback for monitoring events about bottom sheets. */
/** Listener for monitoring events about bottom sheets. */
public abstract static class BottomSheetCallback {
/**
@ -1205,9 +1205,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* Sets a callback to be notified of bottom sheet events.
* Sets a listener to be notified of bottom sheet events.
*
* @param callback The callback to notify when bottom sheet events occur.
* @param callback The listener to notify when bottom sheet events occur.
* @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
* #removeBottomSheetCallback(BottomSheetCallback)} instead
*/
@ -1227,9 +1227,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* Adds a callback to be notified of bottom sheet events.
* Adds a listener to be notified of bottom sheet events.
*
* @param callback The callback to notify when bottom sheet events occur.
* @param callback The listener to notify when bottom sheet events occur.
*/
public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
if (!callbacks.contains(callback)) {
@ -1238,9 +1238,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* Removes a previously added callback.
* Removes a previously added listener.
*
* @param callback The callback to remove.
* @param callback The listener to remove.
*/
public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
callbacks.remove(callback);

View file

@ -73,7 +73,7 @@ class MainFragment :
// --- UI SETUP ---
val context = requireActivity()
// Override the back pressed callback so we can map back navigation to collapsing
// Override the back pressed listener so we can map back navigation to collapsing
// navigation, navigation out of detail views, etc.
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
@ -128,7 +128,7 @@ class MainFragment :
override fun onStart() {
super.onStart()
// Callback could still reasonably fire even if we clear the binding, attach/detach
// Listener could still reasonably fire even if we clear the binding, attach/detach
// our pre-draw listener our listener in onStart/onStop respectively.
requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this)
}
@ -140,7 +140,7 @@ class MainFragment :
override fun onPreDraw(): Boolean {
// We overload CoordinatorLayout far too much to rely on any of it's typical
// callback functionality. Just update all transitions before every draw. Should
// listener functionality. Just update all transitions before every draw. Should
// probably be cheap enough.
val binding = requireBinding()
val playbackSheetBehavior =
@ -221,7 +221,7 @@ class MainFragment :
tryHideAllSheets()
}
// Since the callback is also reliant on the bottom sheets, we must also update it
// Since the listener is also reliant on the bottom sheets, we must also update it
// every frame.
callback.invalidateEnabled()
@ -383,7 +383,7 @@ class MainFragment :
* that the back button should close first, the instance is disabled and back navigation is
* delegated to the system.
*
* Normally, this callback would have just called the [MainActivity.onBackPressed] if there
* Normally, this listener would have just called the [MainActivity.onBackPressed] if there
* were no components to close, but that prevents adaptive back navigation from working on
* Android 14+, so we must do it this way.
*/

View file

@ -187,7 +187,7 @@ class DetailViewModel(application: Application) :
// Nothing to do.
return
}
logD("Opening Song [uid: $uid]")
loadDetailSong(requireMusic(uid))
}
@ -201,7 +201,7 @@ class DetailViewModel(application: Application) :
// Nothing to do.
return
}
logD("Opening Album [uid: $uid]")
_currentAlbum.value = requireMusic<Album>(uid).also { refreshAlbumList(it) }
}
@ -215,7 +215,7 @@ class DetailViewModel(application: Application) :
// Nothing to do.
return
}
logD("Opening Artist [uid: $uid]")
_currentArtist.value = requireMusic<Artist>(uid).also { refreshArtistList(it) }
}
@ -229,7 +229,7 @@ class DetailViewModel(application: Application) :
// Nothing to do.
return
}
logD("Opening Genre [uid: $uid]")
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) }
}

View file

@ -44,7 +44,7 @@ abstract class DetailAdapter(
private val listener: Listener,
itemCallback: DiffUtil.ItemCallback<Item>
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
// Safe to leak this since the callback will not fire during initialization
// Safe to leak this since the listener will not fire during initialization
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
override fun getItemViewType(position: Int) =

View file

@ -127,7 +127,7 @@ class HomeFragment :
// ViewPager2 will nominally consume window insets, which will then break the window
// insets applied to the indexing view before API 30. Fix this by overriding the
// callback with a non-consuming listener.
// listener with a non-consuming listener.
setOnApplyWindowInsetsListener { _, insets -> insets }
// We know that there will only be a fixed amount of tabs, so we manually set this

View file

@ -89,7 +89,7 @@ class BitmapProvider(private val context: Context) {
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform.INSTANCE))
// Override the target in order to deliver the bitmap to the given
// callback.
// listener.
.target(
onSuccess = {
synchronized(this) {

View file

@ -22,11 +22,12 @@ import android.widget.Button
import androidx.recyclerview.widget.RecyclerView
/**
* A basic listener for list interactions. TODO: Supply a ViewHolder on clicks (allows editable
* lists to be standardized into a listener.)
* A basic listener for list interactions.
* @author Alexander Capehart (OxygenCobalt)
*/
interface ClickableListListener {
// TODO: Supply a ViewHolder on clicks
// (allows editable lists to be standardized into a listener.)
/**
* Called when an [Item] in the list is clicked.
* @param item The [Item] that was clicked.
@ -60,15 +61,15 @@ interface SelectableListListener : ClickableListListener {
*/
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
viewHolder.itemView.apply {
// Map clicks to the click callback.
// Map clicks to the click listener.
setOnClickListener { onClick(item) }
// Map long clicks to the selection callback.
// Map long clicks to the selection listener.
setOnLongClickListener {
onSelect(item)
true
}
}
// Map the menu button to the menu opening callback.
// Map the menu button to the menu opening listener.
menuButton.setOnClickListener { onOpenMenu(item, it) }
}
}

View file

@ -166,7 +166,7 @@ class MusicStore private constructor() {
}
}
/** A callback for changes in the music library. */
/** A listener for changes in the music library. */
interface Callback {
/**
* Called when the current [Library] has changed.

View file

@ -26,10 +26,7 @@ import androidx.core.database.getStringOrNull
import androidx.core.database.sqlite.transaction
import java.io.File
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.queryAll
import org.oxycblt.auxio.util.requireBackgroundThread
import org.oxycblt.auxio.util.*
/**
* Defines an Extractor that can load cached music. This is the first step in the music extraction
@ -118,10 +115,7 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
}
override fun populate(rawSong: Song.Raw): ExtractionResult {
val map =
requireNotNull(cacheMap) {
"Must initialize this extractor before populating a raw song."
}
val map = cacheMap ?: return ExtractionResult.NONE
// For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't
@ -181,34 +175,31 @@ private class CacheDatabase(context: Context) :
// Map the cacheable raw song fields to database fields. Cache-able in this context
// means information independent of the file-system, excluding IDs and timestamps required
// to retrieve items from the cache.
val command =
StringBuilder()
.append("CREATE TABLE IF NOT EXISTS $TABLE_RAW_SONGS(")
.append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
.append("${Columns.DATE_ADDED} LONG NOT NULL,")
.append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
.append("${Columns.SIZE} LONG NOT NULL,")
.append("${Columns.DURATION} LONG NOT NULL,")
.append("${Columns.FORMAT_MIME_TYPE} STRING,")
.append("${Columns.MUSIC_BRAINZ_ID} STRING,")
.append("${Columns.NAME} STRING NOT NULL,")
.append("${Columns.SORT_NAME} STRING,")
.append("${Columns.TRACK} INT,")
.append("${Columns.DISC} INT,")
.append("${Columns.DATE} STRING,")
.append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
.append("${Columns.ALBUM_NAME} STRING NOT NULL,")
.append("${Columns.ALBUM_SORT_NAME} STRING,")
.append("${Columns.ALBUM_TYPES} STRING,")
.append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
.append("${Columns.ARTIST_NAMES} STRING,")
.append("${Columns.ARTIST_SORT_NAMES} STRING,")
.append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
.append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
.append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
.append("${Columns.GENRE_NAMES} STRING)")
db.execSQL(command.toString())
db.createTable(TABLE_RAW_SONGS) {
append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
append("${Columns.DATE_ADDED} LONG NOT NULL,")
append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
append("${Columns.SIZE} LONG NOT NULL,")
append("${Columns.DURATION} LONG NOT NULL,")
append("${Columns.FORMAT_MIME_TYPE} STRING,")
append("${Columns.MUSIC_BRAINZ_ID} STRING,")
append("${Columns.NAME} STRING NOT NULL,")
append("${Columns.SORT_NAME} STRING,")
append("${Columns.TRACK} INT,")
append("${Columns.DISC} INT,")
append("${Columns.DATE} STRING,")
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
append("${Columns.ALBUM_SORT_NAME} STRING,")
append("${Columns.ALBUM_TYPES} STRING,")
append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
append("${Columns.ARTIST_NAMES} STRING,")
append("${Columns.ARTIST_SORT_NAMES} STRING,")
append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
append("${Columns.GENRE_NAMES} STRING")
}
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
@ -342,73 +333,50 @@ private class CacheDatabase(context: Context) :
*/
fun write(rawSongs: List<Song.Raw>) {
val start = System.currentTimeMillis()
var position = 0
val database = writableDatabase
database.transaction { delete(TABLE_RAW_SONGS, null, null) }
logD("Cleared raw songs database")
writableDatabase.writeList(rawSongs, TABLE_RAW_SONGS) { _, rawSong ->
ContentValues(22).apply {
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
put(Columns.DATE_ADDED, rawSong.dateAdded)
put(Columns.DATE_MODIFIED, rawSong.dateModified)
while (position < rawSongs.size) {
var i = position
put(Columns.SIZE, rawSong.size)
put(Columns.DURATION, rawSong.durationMs)
put(Columns.FORMAT_MIME_TYPE, rawSong.formatMimeType)
database.transaction {
while (i < rawSongs.size) {
val rawSong = rawSongs[i]
i++
put(Columns.MUSIC_BRAINZ_ID, rawSong.name)
put(Columns.NAME, rawSong.name)
put(Columns.SORT_NAME, rawSong.sortName)
val itemData =
ContentValues(22).apply {
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
put(Columns.DATE_ADDED, rawSong.dateAdded)
put(Columns.DATE_MODIFIED, rawSong.dateModified)
put(Columns.TRACK, rawSong.track)
put(Columns.DISC, rawSong.disc)
put(Columns.DATE, rawSong.date?.toString())
put(Columns.SIZE, rawSong.size)
put(Columns.DURATION, rawSong.durationMs)
put(Columns.FORMAT_MIME_TYPE, rawSong.formatMimeType)
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
put(Columns.ALBUM_NAME, rawSong.albumName)
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
put(Columns.MUSIC_BRAINZ_ID, rawSong.name)
put(Columns.NAME, rawSong.name)
put(Columns.SORT_NAME, rawSong.sortName)
put(
Columns.ARTIST_MUSIC_BRAINZ_IDS,
rawSong.artistMusicBrainzIds.toSQLMultiValue())
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
put(
Columns.ARTIST_SORT_NAMES,
rawSong.artistSortNames.toSQLMultiValue())
put(Columns.TRACK, rawSong.track)
put(Columns.DISC, rawSong.disc)
put(Columns.DATE, rawSong.date?.toString())
put(
Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
put(
Columns.ALBUM_ARTIST_NAMES,
rawSong.albumArtistNames.toSQLMultiValue())
put(
Columns.ALBUM_ARTIST_SORT_NAMES,
rawSong.albumArtistSortNames.toSQLMultiValue())
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
put(Columns.ALBUM_NAME, rawSong.albumName)
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
put(
Columns.ARTIST_MUSIC_BRAINZ_IDS,
rawSong.artistMusicBrainzIds.toSQLMultiValue())
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
put(
Columns.ARTIST_SORT_NAMES,
rawSong.artistSortNames.toSQLMultiValue())
put(
Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
put(
Columns.ALBUM_ARTIST_NAMES,
rawSong.albumArtistNames.toSQLMultiValue())
put(
Columns.ALBUM_ARTIST_SORT_NAMES,
rawSong.albumArtistSortNames.toSQLMultiValue())
put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
}
insert(TABLE_RAW_SONGS, null, itemData)
}
put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
}
// Update the position at the end, if an insert failed at any point, then
// the next iteration should skip it.
position = i
logD("Wrote batch of raw songs. Position is now at $position")
}
logD("Wrote cache in ${System.currentTimeMillis() - start}ms")

View file

@ -91,6 +91,7 @@ abstract class MediaStoreExtractor(
// Filter out music that is not music, if enabled.
if (settings.excludeNonMusic) {
logD("Excluding non-music")
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
}

View file

@ -65,7 +65,7 @@ class MetadataExtractor(
/**
* Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the
* sub-extractors before parsing the metadata itself.
* @param emit A callback that will be invoked with every new [Song.Raw] instance when they are
* @param emit A listener that will be invoked with every new [Song.Raw] instance when they are
* successfully loaded.
*/
suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
@ -131,7 +131,7 @@ class MetadataExtractor(
class Task(context: Context, private val raw: Song.Raw) {
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// callback is used, instead crashing the app entirely.
// listener is used, instead crashing the app entirely.
private val future =
MetadataRetriever.retrieveMetadata(
context,

View file

@ -113,11 +113,11 @@ class Indexer private constructor() {
@Synchronized
fun registerCallback(callback: Callback) {
if (BuildConfig.DEBUG && this.callback != null) {
logW("Callback is already registered")
logW("Listener is already registered")
return
}
// Initialize the callback with the current state.
// Initialize the listener with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
callback.onIndexerStateChanged(currentState)
@ -473,7 +473,7 @@ class Indexer private constructor() {
}
/**
* A callback for rapid-fire changes in the music loading state.
* A listener for rapid-fire changes in the music loading state.
*
* This is only useful for code that absolutely must show the current loading process.
* Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of

View file

@ -78,7 +78,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
// Initialize any callback-dependent components last as we wouldn't want a callback race
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
settings = Settings(this, this)
@ -102,7 +102,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// De-initialize core service components first.
foregroundManager.release()
wakeLock.releaseSafe()
// Then cancel the callback-dependent components to ensure that stray reloading
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
settings.release()
@ -137,8 +137,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a callback as it is bad practice for a shared object to attach to
// the callback system of another.
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.sanitize(newLibrary)
}
// Forward the new library to MusicStore to continue the update process.

View file

@ -19,12 +19,23 @@ package org.oxycblt.auxio.playback
import org.oxycblt.auxio.IntegerTable
/** Represents custom actions available in certain areas of the playback UI. */
/**
* Represents a configuration option for what kind of "secondary" action to show in a particular
* context.
* @author Alexander Capehart (OxygenCobalt)
*/
enum class ActionMode {
/** Use a "Skip next" button for the secondary action. */
NEXT,
/** Use a repeat mode button for the secondary action. */
REPEAT,
/** Use a shuffle mode button for the secondary action. */
SHUFFLE;
/**
* The integer representation of this instance.
* @see fromIntCode
*/
val intCode: Int
get() =
when (this) {
@ -34,9 +45,14 @@ enum class ActionMode {
}
companion object {
/** Convert an int [code] into an instance, or null if it isn't valid. */
fun fromIntCode(code: Int) =
when (code) {
/**
* Convert a [ActionMode] integer representation into an instance.
* @param intCode An integer representation of a [ActionMode]
* @return The corresponding [ActionMode], or null if the [ActionMode] is invalid.
* @see ActionMode.intCode
*/
fun fromIntCode(intCode: Int) =
when (intCode) {
IntegerTable.ACTION_MODE_NEXT -> NEXT
IntegerTable.ACTION_MODE_REPEAT -> REPEAT
IntegerTable.ACTION_MODE_SHUFFLE -> SHUFFLE

View file

@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
/**
* A fragment showing the current playback state in a compact manner. Used as the bar for the
* playback sheet.
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
@ -49,30 +48,32 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding: FragmentPlaybackBarBinding,
savedInstanceState: Bundle?
) {
super.onBindingCreated(binding, savedInstanceState)
val context = requireContext()
// --- UI SETUP ---
binding.root.apply {
setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) }
setOnLongClickListener {
playbackModel.song.value?.let(navModel::exploreNavigateTo)
true
}
}
// Set up marquee on song information
binding.playbackSong.isSelected = true
binding.playbackInfo.isSelected = true
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
// Set up actions
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
setupSecondaryActions(binding, Settings(context))
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources
// using a ColorStateList in the resources.
binding.playbackProgressBar.trackColor =
context.getColorCompat(R.color.sel_track).defaultColor
// -- VIEWMODEL SETUP ---
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
collectImmediately(playbackModel.positionDs, ::updatePosition)
@ -80,6 +81,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
super.onDestroyBinding(binding)
// Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false
binding.playbackInfo.isSelected = false
}
@ -98,7 +100,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding.playbackSecondaryAction.apply {
contentDescription = getString(R.string.desc_change_repeat)
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
setOnClickListener { playbackModel.incrementRepeatMode() }
setOnClickListener { playbackModel.toggleRepeatMode() }
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
}
}
@ -132,6 +134,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private fun updateRepeat(repeatMode: RepeatMode) {
requireBinding().playbackSecondaryAction.apply {
setIconResource(repeatMode.icon)
// Icon tinting is controlled through isActivated, so update that flag as well.
isActivated = repeatMode != RepeatMode.NONE
}
}

View file

@ -25,13 +25,12 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
/**
* The coordinator layout behavior used for the playback sheet, hacking in the many fixes required
* to make bottom sheets like this work.
* The [BaseBottomSheetBehavior] for the playback bottom sheet. This bottom sheet
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
@ -57,6 +56,8 @@ class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: Attr
override fun createBackground(context: Context) =
LayerDrawable(
arrayOf(
// Add another colored background so that there is always an obscuring
// element even as the actual "background" element is faded out.
MaterialShapeDrawable(sheetBackgroundDrawable.shapeAppearanceModel).apply {
fillColor = sheetBackgroundDrawable.fillColor
},

View file

@ -24,8 +24,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
@ -33,6 +33,7 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
@ -42,18 +43,16 @@ import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [Fragment] that displays more information about the song, along with more media controls.
* A [ViewBindingFragment] more information about the currently playing song, alongside all
* available controls.
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Make seek thumb grow when selected
*/
class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>() {
class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(), Toolbar.OnMenuItemClickListener, StyledSeekBar.Listener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
// AudioEffect expects you to use startActivityForResult with the panel intent. Use
// the contract analogue for this since there is no built-in contract for AudioEffect.
private val activityLauncher by lifecycleObject {
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
// contract analogue for this intent, so the generic contract is used instead.
private val equalizerLauncher by lifecycleObject {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
// Nothing to do
}
@ -66,8 +65,9 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
binding: FragmentPlaybackPanelBinding,
savedInstanceState: Bundle?
) {
// --- UI SETUP ---
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { view, insets ->
val bars = insets.systemBarInsetsCompat
view.updatePadding(top = bars.top, bottom = bars.bottom)
@ -76,39 +76,34 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
binding.playbackToolbar.apply {
setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) }
setOnMenuItemClickListener {
handleMenuItem(it)
true
}
setOnMenuItemClickListener(this@PlaybackPanelFragment)
}
// Make sure we enable marquee on the song info
// Set up marquee on song information, alongside click handlers that navigate to each
// respective item.
binding.playbackSong.apply {
isSelected = true
setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) }
}
binding.playbackArtist.apply {
isSelected = true
setOnClickListener { playbackModel.song.value?.let { showCurrentArtist() } }
setOnClickListener { navigateToCurrentArtist() }
}
binding.playbackAlbum.apply {
isSelected = true
setOnClickListener { playbackModel.song.value?.let { showCurrentAlbum() } }
setOnClickListener { navigateToCurrentAlbum() }
}
binding.playbackSeekBar.onSeekConfirmed = playbackModel::seekTo
binding.playbackSeekBar.listener = this
binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() }
// Set up actions
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
// --- VIEWMODEL SETUP --
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.parent, ::updateParent)
collectImmediately(playbackModel.positionDs, ::updatePosition)
@ -118,32 +113,40 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
}
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
// Leaving marquee on will cause a leak
binding.playbackToolbar.setOnMenuItemClickListener(null)
// Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false
binding.playbackArtist.isSelected = false
binding.playbackAlbum.isSelected = false
}
private fun handleMenuItem(item: MenuItem) {
override fun onMenuItemClick(item: MenuItem) =
when (item.itemId) {
R.id.action_open_equalizer -> {
// Launch the system equalizer app, if possible.
val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
// Provide audio session ID so equalizer can show options for this app
// in particular.
.putExtra(
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
// Signal music type so that the equalizer settings are appropriate for
// music playback.
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
try {
activityLauncher.launch(equalizerIntent)
equalizerLauncher.launch(equalizerIntent)
} catch (e: ActivityNotFoundException) {
requireContext().showToast(R.string.err_no_app)
}
true
}
R.id.action_go_artist -> {
showCurrentArtist()
navigateToCurrentArtist()
true
}
R.id.action_go_album -> {
showCurrentAlbum()
navigateToCurrentAlbum()
true
}
R.id.action_song_detail -> {
playbackModel.song.value?.let { song ->
@ -151,8 +154,13 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
MainNavigationAction.Directions(
MainFragmentDirections.actionShowDetails(song.uid)))
}
true
}
else -> false
}
override fun onSeekConfirmed(positionDs: Long) {
playbackModel.seekTo(positionDs)
}
private fun updateSong(song: Song?) {
@ -192,12 +200,14 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
requireBinding().playbackShuffle.isActivated = isShuffled
}
private fun showCurrentArtist() {
/** Navigate to one of the currently playing [Song]'s Artists. */
private fun navigateToCurrentArtist() {
val song = playbackModel.song.value ?: return
navModel.exploreNavigateTo(song.artists)
}
private fun showCurrentAlbum() {
/** Navigate to the currently playing [Song]'s albums. */
private fun navigateToCurrentAlbum() {
val song = playbackModel.song.value ?: return
navModel.exploreNavigateTo(song.album)
}

View file

@ -20,52 +20,65 @@ package org.oxycblt.auxio.playback
import android.text.format.DateUtils
import org.oxycblt.auxio.util.logD
/** Converts a long in milliseconds to a long in deci-seconds */
/**
* Convert milliseconds into deci-seconds (1/10th of a second).
* @return A converted deci-second value.
*/
fun Long.msToDs() = floorDiv(100)
/** Converts a long in milliseconds to a long in seconds */
/**
* Convert milliseconds into seconds.
* @return A converted second value.
*/
fun Long.msToSecs() = floorDiv(1000)
/** Converts a long in deci-seconds to a long in milliseconds. */
/**
* Convert deci-seconds (1/10th of a second) into milliseconds.
* @return A converted millisecond value.
*/
fun Long.dsToMs() = times(100)
/** Converts a long in deci-seconds to a long in seconds. */
/**
* Convert deci-seconds (1/10th of a second) into seconds.
* @return A converted second value.
*/
fun Long.dsToSecs() = floorDiv(10)
/** Converts a long in seconds to a long in milliseconds. */
/**
* Convert seconds into milliseconds.
* @return A converted millisecond value.
*/
fun Long.secsToMs() = times(1000)
/**
* Convert a [Long] of milliseconds into a string duration.
* Convert a millisecond value into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0.
*/
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed)
/**
* Convert a [Long] of deci-seconds into a string duration.
// * Format a deci-second value (1/10th of a second) into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0.
*/
fun Long.formatDurationDs(isElapsed: Boolean) = dsToSecs().formatDurationSecs(isElapsed)
/**
* Convert a [Long] of seconds into a string duration.
* Convert a second value into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0.
*/
fun Long.formatDurationSecs(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) {
logD("Non-elapsed duration is zero, using --:--")
// Non-elapsed duration is zero, return default value.
return "--:--"
}
var durationString = DateUtils.formatElapsedTime(this)
// If the duration begins with a excess zero [e.g 01:42], then cut it off.
// Remove trailing zero values [i.e 01:42]. This is primarily for aesthetics.
if (durationString[0] == '0') {
durationString = durationString.slice(1 until durationString.length)
}
return durationString
}

View file

@ -34,284 +34,60 @@ import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.context
/**
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
*
* **PLEASE Use this instead of [PlaybackStateManager], UIs are extremely volatile and this provides
* an interface that properly sanitizes input and abstracts functions unlike the master class.**
*
* The ViewModel that provides a safe UI frontend for [PlaybackStateManager].
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Queue additions without a song should map to playing selected
*/
class PlaybackViewModel(application: Application) :
AndroidViewModel(application), PlaybackStateManager.Callback {
private val settings = Settings(application)
private val playbackManager = PlaybackStateManager.getInstance()
private var lastPositionJob: Job? = null
private val _song = MutableStateFlow<Song?>(null)
/** The current song. */
/** The currently playing song. */
val song: StateFlow<Song?>
get() = _song
private val _parent = MutableStateFlow<MusicParent?>(null)
/** The current model that is being played from, such as an [Album] or [Artist] */
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
val parent: StateFlow<MusicParent?> = _parent
private val _isPlaying = MutableStateFlow(false)
/** Whether playback is ongoing or paused.*/
val isPlaying: StateFlow<Boolean>
get() = _isPlaying
private val _positionDs = MutableStateFlow(0L)
/** The current playback position, in *deci-seconds* */
/** The current position, in deci-seconds (1/10th of a second). */
val positionDs: StateFlow<Long>
get() = _positionDs
private val _repeatMode = MutableStateFlow(RepeatMode.NONE)
/** The current repeat mode, see [RepeatMode] for more information */
/** The current [RepeatMode]. */
val repeatMode: StateFlow<RepeatMode>
get() = _repeatMode
private val _isShuffled = MutableStateFlow(false)
/** Whether the queue is shuffled or not. */
val isShuffled: StateFlow<Boolean>
get() = _isShuffled
/** The current ID of the app's audio session. */
val currentAudioSessionId: Int?
get() = playbackManager.currentAudioSessionId
private var lastPositionJob: Job? = null
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
/** Flag for resolving an ambiguous artist choice when playing from a song's artists. */
/**
* Flag signaling to open a picker dialog in order to resolve an ambiguous [Artist] choice when
* playing a [Song] from one of it's [Artist]s.
* @see playFromArtist
*/
val artistPlaybackPickerSong: StateFlow<Song?>
get() = _artistPlaybackPickerSong
/**
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
* available.
*/
val currentAudioSessionId: Int?
get() = playbackManager.currentAudioSessionId
init {
playbackManager.addCallback(this)
}
// --- PLAYING FUNCTIONS ---
/** Play a [song] from all songs. */
fun playFromAll(song: Song) {
playbackManager.play(song, null, settings)
}
/** Shuffle all songs */
fun shuffleAll() {
playbackManager.play(null, null, settings, true)
}
/** Play a song from it's album. */
fun playFromAlbum(song: Song) {
playbackManager.play(song, song.album, settings)
}
/** Play a song from it's artist. */
fun playFromArtist(song: Song, artist: Artist? = null) {
if (artist != null) {
check(artist in song.artists) { "Artist not in song artists" }
playbackManager.play(song, artist, settings)
} else {
if (song.artists.size == 1) {
playbackManager.play(song, song.artists[0], settings)
} else {
_artistPlaybackPickerSong.value = song
}
}
}
/** Complete the picker opening process when playing from an artist. */
fun finishPlaybackArtistPicker() {
_artistPlaybackPickerSong.value = null
}
/** Play a song from the specific genre that contains the song. */
fun playFromGenre(song: Song, genre: Genre) {
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
playbackManager.play(song, genre, settings)
}
/** Play an [album]. */
fun play(album: Album) {
playbackManager.play(null, album, settings, false)
}
/** Play an [artist]. */
fun play(artist: Artist) {
playbackManager.play(null, artist, settings, false)
}
/** Play a [genre]. */
fun play(genre: Genre) {
playbackManager.play(null, genre, settings, false)
}
/** Shuffle an [album]. */
fun shuffle(album: Album) {
playbackManager.play(null, album, settings, true)
}
/** Shuffle an [artist]. */
fun shuffle(artist: Artist) {
playbackManager.play(null, artist, settings, true)
}
/** Shuffle a [genre]. */
fun shuffle(genre: Genre) {
playbackManager.play(null, genre, settings, true)
}
/**
* Perform the given [InternalPlayer.Action].
*
* These are a class of playback actions that must have music present to function, usually
* alongside a context too. Examples include:
* - Opening files
* - Restoring the playback state
* - App shortcuts
*/
fun startAction(action: InternalPlayer.Action) {
playbackManager.startAction(action)
}
// --- PLAYER FUNCTIONS ---
/** Update the position and push it to [PlaybackStateManager] */
fun seekTo(positionDs: Long) {
playbackManager.seekTo(positionDs.dsToMs())
}
// --- QUEUE FUNCTIONS ---
/** Skip to the next song. */
fun next() {
playbackManager.next()
}
/** Skip to the previous song. */
fun prev() {
playbackManager.prev()
}
/** Add a [Song] to the top of the queue. */
fun playNext(song: Song) {
playbackManager.playNext(song)
}
/** Add an [Album] to the top of the queue. */
fun playNext(album: Album) {
playbackManager.playNext(settings.detailAlbumSort.songs(album.songs))
}
/** Add an [Artist] to the top of the queue. */
fun playNext(artist: Artist) {
playbackManager.playNext(settings.detailArtistSort.songs(artist.songs))
}
/** Add a [Genre] to the top of the queue. */
fun playNext(genre: Genre) {
playbackManager.playNext(settings.detailGenreSort.songs(genre.songs))
}
/** Add a selection [selection] to the top of the queue. */
fun playNext(selection: List<Music>) {
playbackManager.playNext(selectionToSongs(selection))
}
/** Add a [Song] to the end of the queue. */
fun addToQueue(song: Song) {
playbackManager.addToQueue(song)
}
/** Add an [Album] to the end of the queue. */
fun addToQueue(album: Album) {
playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs))
}
/** Add an [Artist] to the end of the queue. */
fun addToQueue(artist: Artist) {
playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs))
}
/** Add a [Genre] to the end of the queue. */
fun addToQueue(genre: Genre) {
playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs))
}
/** Add a selection [selection] to the top of the queue. */
fun addToQueue(selection: List<Music>) {
playbackManager.addToQueue(selectionToSongs(selection))
}
private fun selectionToSongs(selection: List<Music>): List<Song> {
return selection.flatMap {
when (it) {
is Album -> settings.detailAlbumSort.songs(it.songs)
is Artist -> settings.detailArtistSort.songs(it.songs)
is Genre -> settings.detailGenreSort.songs(it.songs)
is Song -> listOf(it)
}
}
}
// --- STATUS FUNCTIONS ---
/** Flip the playing status, e.g from playing to paused */
fun invertPlaying() {
playbackManager.changePlaying(!playbackManager.playerState.isPlaying)
}
/** Flip the shuffle status, e.g from on to off. Will keep song by default. */
fun invertShuffled() {
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
}
/** Increment the repeat mode, e.g from [RepeatMode.NONE] to [RepeatMode.ALL] */
fun incrementRepeatMode() {
playbackManager.repeatMode = playbackManager.repeatMode.increment()
}
// --- SAVE/RESTORE FUNCTIONS ---
/**
* Force save the current [PlaybackStateManager] state to the database. [onDone] will be called
* with true if it was done, or false if an error occurred.
*/
fun savePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch {
val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context))
onDone(saved)
}
}
/**
* Wipe the saved playback state (if any). [onDone] will be called with true if it was
* successfully done, or false if an error occurred.
*/
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch {
val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context))
onDone(wiped)
}
}
/**
* Force restore the last [PlaybackStateManager] saved state, regardless of if a library exists
* or not. [onDone] will be called with true if it was successfully done, or false if there was
* no state, a library was not present, or there was an error.
*/
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch {
val restored =
playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true)
onDone(restored)
}
}
// --- OVERRIDES ---
override fun onCleared() {
playbackManager.removeCallback(this)
}
@ -327,14 +103,16 @@ class PlaybackViewModel(application: Application) :
override fun onStateChanged(state: InternalPlayer.State) {
_isPlaying.value = state.isPlaying
_positionDs.value = state.calculateElapsedPosition().msToDs()
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
// Start watching the position again
// Cancel the previous position job relying on old state information and create
// a new one.
lastPositionJob?.cancel()
lastPositionJob =
viewModelScope.launch {
while (true) {
_positionDs.value = state.calculateElapsedPosition().msToDs()
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
// Wait a deci-second for the next position tick.
delay(100)
}
}
@ -347,4 +125,297 @@ class PlaybackViewModel(application: Application) :
override fun onRepeatChanged(repeatMode: RepeatMode) {
_repeatMode.value = repeatMode
}
// --- PLAYING FUNCTIONS ---
/**
* Play the given [Song] from all songs in the music library.
* @param song The [Song] to play.
*/
fun playFromAll(song: Song) {
playbackManager.play(song, null, settings)
}
/** Shuffle all songs in the music library. */
fun shuffleAll() {
playbackManager.play(null, null, settings, true)
}
/**
* Play a [Song] from it's [Album].
*/
fun playFromAlbum(song: Song) {
playbackManager.play(song, song.album, settings)
}
/**
* Play a [Song] from one of it's [Artist]s.
* @param song The [Song] to play.
* @param artist The [Artist] to play from. Must be linked to the [Song]. If null, the user
* will be prompted on what artist to play. Defaults to null.
*/
fun playFromArtist(song: Song, artist: Artist? = null) {
if (artist != null) {
check(artist in song.artists) { "Artist not in song artists" }
playbackManager.play(song, artist, settings)
} else if (song.artists.size == 1) {
playbackManager.play(song, song.artists[0], settings)
} else {
_artistPlaybackPickerSong.value = song
}
}
/**
* Mark the [Artist] playback choice process as complete. This should occur when the [Artist]
* choice dialog is opened after this flag is detected.
* @see playFromArtist
*/
fun finishPlaybackArtistPicker() {
_artistPlaybackPickerSong.value = null
}
/**
* PLay a [Song] from one of it's [Genre]s.
* @param song The [Song] to play.
* @param genre The [Genre] to play from. Must be linked to the [Song].
*/
fun playFromGenre(song: Song, genre: Genre) {
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
playbackManager.play(song, genre, settings)
}
/**
* Play an [Album].
* @param album The [Album] to play.
*/
fun play(album: Album) {
playbackManager.play(null, album, settings, false)
}
/**
* Play an [Artist].
* @param artist The [Artist] to play.
*/
fun play(artist: Artist) {
playbackManager.play(null, artist, settings, false)
}
/**
* Play a [Genre].
* @param genre The [Genre] to play.
*/
fun play(genre: Genre) {
playbackManager.play(null, genre, settings, false)
}
/**
* Shuffle an [Album].
* @param album The [Album] to shuffle.
*/
fun shuffle(album: Album) {
playbackManager.play(null, album, settings, true)
}
/**
* Shuffle an [Artist].
* @param artist The [Artist] to shuffle.
*/
fun shuffle(artist: Artist) {
playbackManager.play(null, artist, settings, true)
}
/**
* Shuffle an [Genre].
* @param genre The [Genre] to shuffle.
*/
fun shuffle(genre: Genre) {
playbackManager.play(null, genre, settings, true)
}
/**
* Start the given [InternalPlayer.Action] to be completed eventually. This can be used
* to enqueue a playback action at startup to then occur when the music library is fully loaded.
* @param action The [InternalPlayer.Action] to perform eventually.
*/
fun startAction(action: InternalPlayer.Action) {
playbackManager.startAction(action)
}
// --- PLAYER FUNCTIONS ---
/**
* Seek to the given position in the currently playing [Song].
* @param positionDs The position to seek to, in deci-seconds (1/10th of a second).
*/
fun seekTo(positionDs: Long) {
playbackManager.seekTo(positionDs.dsToMs())
}
// --- QUEUE FUNCTIONS ---
/** Skip to the next [Song]. */
fun next() {
playbackManager.next()
}
/** Skip to the previous [Song]. */
fun prev() {
playbackManager.prev()
}
/**
* Add a [Song] to the top of the queue.
* @param song The [Song] to add.
*/
fun playNext(song: Song) {
// TODO: Queue additions without a playing song should map to queued items
// (impossible until queue rework)
playbackManager.playNext(song)
}
/**
* Add a [Album] to the top of the queue.
* @param album The [Album] to add.
*/
fun playNext(album: Album) {
playbackManager.playNext(settings.detailAlbumSort.songs(album.songs))
}
/**
* Add a [Artist] to the top of the queue.
* @param artist The [Artist] to add.
*/
fun playNext(artist: Artist) {
playbackManager.playNext(settings.detailArtistSort.songs(artist.songs))
}
/**
* Add a [Genre] to the top of the queue.
* @param genre The [Genre] to add.
*/
fun playNext(genre: Genre) {
playbackManager.playNext(settings.detailGenreSort.songs(genre.songs))
}
/**
* Add a selection to the top of the queue.
* @param selection The [Music] selection to add.
*/
fun playNext(selection: List<Music>) {
playbackManager.playNext(selectionToSongs(selection))
}
/**
* Add a [Song] to the end of the queue.
* @param song The [Song] to add.
*/
fun addToQueue(song: Song) {
playbackManager.addToQueue(song)
}
/**
* Add a [Album] to the end of the queue.
* @param album The [Album] to add.
*/
fun addToQueue(album: Album) {
playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs))
}
/**
* Add a [Artist] to the end of the queue.
* @param artist The [Artist] to add.
*/
fun addToQueue(artist: Artist) {
playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs))
}
/**
* Add a [Genre] to the end of the queue.
* @param genre The [Genre] to add.
*/
fun addToQueue(genre: Genre) {
playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs))
}
/**
* Add a selection to the end of the queue.
* @param selection The [Music] selection to add.
*/
fun addToQueue(selection: List<Music>) {
playbackManager.addToQueue(selectionToSongs(selection))
}
// --- STATUS FUNCTIONS ---
/** Toggle [isPlaying] (i.e from playing to paused) */
fun toggleIsPlaying() {
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
}
/** Toggle [isShuffled] (ex. from on to off) */
fun invertShuffled() {
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
}
/**
* Toggle [repeatMode] (ex. from [RepeatMode.NONE] to [RepeatMode.TRACK])
* @see RepeatMode.increment
*/
fun toggleRepeatMode() {
playbackManager.repeatMode = playbackManager.repeatMode.increment()
}
// --- SAVE/RESTORE FUNCTIONS ---
/**
* Force-save the current playback state.
* @param onDone Called when the save is completed with true if successful, and false otherwise.
*/
fun savePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch {
val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context))
onDone(saved)
}
}
/**
* Clear the current playback state.
* @param onDone Called when the wipe is completed with true if successful, and false otherwise.
*/
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch {
val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context))
onDone(wiped)
}
}
/**
* Force-restore the current playback state.
* @param onDone Called when the restoration is completed with true if successful, and false
* otherwise.
*/
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch {
val restored =
playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true)
onDone(restored)
}
}
/**
* Convert the given selection to a list of [Song]s.
* @param selection The selection of [Music] to convert.
* @return A [Song] list containing the child items of any [MusicParent] instances in the list
* alongside the unchanged [Song]s or the original selection.
*/
private fun selectionToSongs(selection: List<Music>): List<Song> {
return selection.flatMap {
when (it) {
is Album -> settings.detailAlbumSort.songs(it.songs)
is Artist -> settings.detailArtistSort.songs(it.songs)
is Genre -> settings.detailGenreSort.songs(it.songs)
is Song -> listOf(it)
}
}
}
}

View file

@ -24,7 +24,7 @@ import android.view.WindowInsets
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels

View file

@ -90,9 +90,8 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
// If requested, scroll to a new item (occurs when the index moves)
val scrollTo = queueModel.scrollTo
if (scrollTo != null) {
// Do not scroll to indices that are not in the currently visible range.
// This prevents the queue from jumping around when the user is trying to
// navigate the queue.
// Do not scroll to indices that are in the currently visible range. As that would
// lead to the queue jumping around every time goto is called.
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition()
val end = lmm.findLastCompletelyVisibleItemPosition()

View file

@ -22,51 +22,88 @@ import android.os.SystemClock
import android.support.v4.media.session.PlaybackStateCompat
import org.oxycblt.auxio.music.Song
/** Represents a class capable of managing the internal player. */
/**
* An interface for internal audio playback. This can be used to coordinate what occurs in the
* background playback task.
* @author Alexander Capehart (OxygenCobalt)
*/
interface InternalPlayer {
/** The audio session ID of the player instance. */
/** The ID of the audio session started by this instance. */
val audioSessionId: Int
/** Whether the player should rewind instead of going to the previous song. */
/** Whether the player should rewind before skipping back. */
val shouldRewindWithPrev: Boolean
fun makeState(durationMs: Long): State
/** Called when a new song should be loaded into the player. */
/**
* Load a new [Song] into the internal player.
* @param song The [Song] to load, or null if playback should stop entirely.
* @param play Whether to start playing when the [Song] is loaded.
*/
fun loadSong(song: Song?, play: Boolean)
/** Seek to [positionMs] in the player. */
fun seekTo(positionMs: Long)
/** Called when the playing state needs to be changed. */
fun changePlaying(isPlaying: Boolean)
/**
* Called when an [Action] has been queued and this [InternalPlayer] is available to handle it.
* @param action The [Action] to perform.
* @return true if the action was handled, false otherwise.
*/
fun performAction(action: Action): Boolean
/**
* Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the
* action was consumed, false otherwise.
* Get a [State] corresponding to the current player state.
* @param durationMs The duration of the currently playing track, in milliseconds.
* Required since the internal player cannot obtain an accurate duration itself.
*/
fun onAction(action: Action): Boolean
fun getState(durationMs: Long): State
/**
* Seek to a given position in the internal player.
* @param positionMs The position to seek to, in milliseconds.
*/
fun seekTo(positionMs: Long)
/**
* Set whether the player should play or not.
* @param isPlaying Whether to play or pause the current playback.
*/
fun setPlaying(isPlaying: Boolean)
/**
* Possible long-running background tasks handled by the background playback task.
*/
sealed class Action {
/** Restore the previously saved playback state. */
object RestoreState : Action()
/**
* Start shuffled playback of the entire music library.
* Analogous to the "Shuffle All" shortcut.
*/
object ShuffleAll : Action()
/**
* Start playing an audio file at the given [Uri].
* @param uri The [Uri] of the audio file to start playing.
*/
data class Open(val uri: Uri) : Action()
}
class State
private constructor(
/**
* Whether the user has actually chosen to play this audio. The player might not actually be
* playing at this time.
*/
/** Whether the player is actively playing audio or set to play audio in the future. */
val isPlaying: Boolean,
/** Whether the player is actually advancing through the audio. */
/** Whether the player is actively playing audio in this moment. */
private val isAdvancing: Boolean,
/** The initial position at update time. */
/** The position when this instance was created, in milliseconds. */
private val initPositionMs: Long,
/** The time this instance was created. */
/** The time this instance was created, as a unix epoch timestamp. */
private val creationTime: Long
) {
/**
* Calculate the estimated position that the player is now at. If the player's position is
* not advancing, this will be the initial position. Otherwise, this will be the position
* plus the elapsed time since this state was uploaded.
* Calculate the "real" playback position this instance contains, in milliseconds.
* @return If paused, the original position will be returned. Otherwise, it will be
* the original position plus the time elapsed since this state was created.
*/
fun calculateElapsedPosition() =
fun calculateElapsedPositionMs() =
if (isAdvancing) {
initPositionMs + (SystemClock.elapsedRealtime() - creationTime)
} else {
@ -75,7 +112,11 @@ interface InternalPlayer {
initPositionMs
}
/** Load this state into the analogous [PlaybackStateCompat.Builder]. */
/**
* Load this instance into a [PlaybackStateCompat].
* @param builder The [PlaybackStateCompat.Builder] to mutate.
* @return The same [PlaybackStateCompat.Builder] for easy chaining.
*/
fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
builder.setState(
// State represents the user's preference, not the actual player state.
@ -94,8 +135,8 @@ interface InternalPlayer {
},
creationTime)
// Equality ignores the creation time to prevent functionally
// identical states from being equal.
// Equality ignores the creation time to prevent functionally identical states
// from being non-equal.
override fun equals(other: Any?) =
other is State &&
@ -111,21 +152,20 @@ interface InternalPlayer {
}
companion object {
/** Create a new instance of this state. */
/**
* Create a new instance.
* @param isPlaying Whether the player is actively playing audio or set to play audio
* in the future.
* @param isAdvancing Whether the player is actively playing audio in this moment.
* @param positionMs The current position of the player.
*/
fun new(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
State(
isPlaying,
// Minor sanity check: Make sure that advancing can't occur if the
// main playing value is paused.
// Minor sanity check: Make sure that advancing can't occur if already paused.
isPlaying && isAdvancing,
positionMs,
SystemClock.elapsedRealtime())
}
}
sealed class Action {
object RestoreState : Action()
object ShuffleAll : Action()
data class Open(val uri: Uri) : Action()
}
}

View file

@ -21,26 +21,39 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.provider.BaseColumns
import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll
import org.oxycblt.auxio.util.requireBackgroundThread
import org.oxycblt.auxio.util.*
/**
* A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists.
* But that would needlessly bloat my app and has crippling bugs.
* A [SQLiteDatabase] that persists the current playback state for future app lifecycles.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackStateDatabase private constructor(context: Context) :
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
createTable(db, TABLE_STATE)
createTable(db, TABLE_QUEUE)
// Here, we have to split the database into two tables. One contains the queue with
// an indefinite amount of items, and the other contains only one entry consisting
// of the non-queue parts of the state, such as the playback position.
db.createTable(TABLE_STATE) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${StateColumns.INDEX} INTEGER NOT NULL,")
append("${StateColumns.POSITION} LONG NOT NULL,")
append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,")
append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
append("${StateColumns.SONG_UID} STRING,")
append("${StateColumns.PARENT_UID} STRING")
}
db.createTable(TABLE_QUEUE) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${QueueColumns.SONG_UID} STRING NOT NULL")
}
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
@ -55,55 +68,28 @@ class PlaybackStateDatabase private constructor(context: Context) :
}
}
// --- DATABASE CONSTRUCTION FUNCTIONS ---
/** Create a table for this database. */
private fun createTable(db: SQLiteDatabase, name: String) {
val command = StringBuilder()
command.append("CREATE TABLE IF NOT EXISTS $name(")
if (name == TABLE_STATE) {
constructStateTable(command)
} else if (name == TABLE_QUEUE) {
constructQueueTable(command)
}
db.execSQL(command.toString())
}
/** Construct a [StateColumns] table */
private fun constructStateTable(command: StringBuilder) =
command
.append("${StateColumns.ID} LONG PRIMARY KEY,")
.append("${StateColumns.SONG_UID} STRING,")
.append("${StateColumns.POSITION} LONG NOT NULL,")
.append("${StateColumns.PARENT_UID} STRING,")
.append("${StateColumns.INDEX} INTEGER NOT NULL,")
.append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
.append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL)")
/** Construct a [QueueColumns] table */
private fun constructQueueTable(command: StringBuilder) =
command
.append("${QueueColumns.ID} LONG PRIMARY KEY,")
.append("${QueueColumns.SONG_UID} STRING NOT NULL)")
// --- INTERFACE FUNCTIONS ---
/**
* Read a persisted [SavedState] from the database.
* @param library [MusicStore.Library] required to restore [SavedState].
* @return A persisted [SavedState], or null if one could not be found.
*/
fun read(library: MusicStore.Library): SavedState? {
requireBackgroundThread()
// Read the saved state and queue. If the state is non-null, that must imply an
// existent, albeit possibly empty, queue.
val rawState = readRawState() ?: return null
val queue = readQueue(library)
// Correct the index to match up with a possibly shortened queue (file removals/changes)
// Correct the index to match up with a queue that has possibly been shortened due to
// song removals.
var actualIndex = rawState.index
while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) {
actualIndex--
}
// Restore parent item from the music library. If this fails, then the playback mode
// reverts to "All Songs", which is considered okay.
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
return SavedState(
index = actualIndex,
parent = parent,
@ -113,22 +99,19 @@ class PlaybackStateDatabase private constructor(context: Context) :
isShuffled = rawState.isShuffled)
}
private fun readRawState(): RawState? {
return readableDatabase.queryAll(TABLE_STATE) { cursor ->
if (cursor.count == 0) {
private fun readRawState() =
readableDatabase.queryAll(TABLE_STATE) { cursor ->
if (!cursor.moveToFirst()) {
// Empty, nothing to do.
return@queryAll null
}
val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX)
val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION)
val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE)
val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED)
val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID)
val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID)
cursor.moveToFirst()
RawState(
index = cursor.getInt(indexIndex),
positionMs = cursor.getLong(posIndex),
@ -139,15 +122,10 @@ class PlaybackStateDatabase private constructor(context: Context) :
?: return@queryAll null,
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
}
}
private fun readQueue(library: MusicStore.Library): MutableList<Song> {
requireBackgroundThread()
private fun readQueue(library: MusicStore.Library): List<Song> {
val queue = mutableListOf<Song>()
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
if (cursor.count == 0) return@queryAll
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
while (cursor.moveToNext()) {
val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue
@ -157,15 +135,19 @@ class PlaybackStateDatabase private constructor(context: Context) :
}
logD("Successfully read queue of ${queue.size} songs")
return queue
}
/** Clear the previously written [SavedState] and write a new one. */
/**
* Clear the previous [SavedState] and write a new one.
* @param state The new [SavedState] to write, or null to clear the database entirely.
*/
fun write(state: SavedState?) {
requireBackgroundThread()
// Only bother saving a state if a song is actively playing from one.
// This is not the case with a null state or a state with an out-of-bounds index.
if (state != null && state.index in state.queue.indices) {
// Transform saved state into raw state, which can then be written to the database.
val rawState =
RawState(
index = state.index,
@ -174,15 +156,14 @@ class PlaybackStateDatabase private constructor(context: Context) :
isShuffled = state.isShuffled,
songUid = state.queue[state.index].uid,
parentUid = state.parent?.uid)
writeRawState(rawState)
writeQueue(state.queue)
logD("Wrote state")
} else {
writeRawState(null)
writeQueue(null)
logD("Cleared state")
}
logD("Wrote state to database")
}
private fun writeRawState(rawState: RawState?) {
@ -192,7 +173,7 @@ class PlaybackStateDatabase private constructor(context: Context) :
if (rawState != null) {
val stateData =
ContentValues(7).apply {
put(StateColumns.ID, 0)
put(BaseColumns._ID, 0)
put(StateColumns.SONG_UID, rawState.songUid.toString())
put(StateColumns.POSITION, rawState.positionMs)
put(StateColumns.PARENT_UID, rawState.parentUid?.toString())
@ -206,45 +187,25 @@ class PlaybackStateDatabase private constructor(context: Context) :
}
}
/** Write a queue to the database. */
private fun writeQueue(queue: List<Song>?) {
val database = writableDatabase
database.transaction { delete(TABLE_QUEUE, null, null) }
logD("Wiped queue db")
if (queue != null) {
val idStart = queue.size
logD("Beginning queue write [start: $idStart]")
var position = 0
while (position < queue.size) {
var i = position
database.transaction {
while (i < queue.size) {
val song = queue[i]
i++
val itemData =
ContentValues(2).apply {
put(QueueColumns.ID, idStart + i)
put(QueueColumns.SONG_UID, song.uid.toString())
}
insert(TABLE_QUEUE, null, itemData)
}
}
// Update the position at the end, if an insert failed at any point, then
// the next iteration should skip it.
position = i
logD("Wrote batch of songs. Position is now at $position")
writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song ->
ContentValues(2).apply {
put(BaseColumns._ID, i)
put(QueueColumns.SONG_UID, song.uid.toString())
}
}
}
/**
* A condensed representation of the playback state that can be persisted.
* @param index The position of the currently playing item in the queue. Can be -1 if the
* persisted index no longer exists.
* @param queue The [Song] queue.
* @param parent The [MusicParent] item currently being played from
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
* @param isShuffled Whether the queue is shuffled or not.
*/
data class SavedState(
val index: Int,
val queue: List<Song>,
@ -254,40 +215,63 @@ class PlaybackStateDatabase private constructor(context: Context) :
val isShuffled: Boolean
)
/**
* A lower-level form of [SavedState] that contains additional information to create
* a more reliable restoration process.
*/
private data class RawState(
/** @see SavedState.index */
val index: Int,
/** @see SavedState.positionMs */
val positionMs: Long,
/** @see SavedState.repeatMode */
val repeatMode: RepeatMode,
/** @see SavedState.isShuffled */
val isShuffled: Boolean,
/**
* The [Music.UID] of the [Song] that was originally in the queue at [index].
* This can be used to restore the currently playing item in the queue if
* the index mapping changed.
*/
val songUid: Music.UID,
/** @see SavedState.parent */
val parentUid: Music.UID?
)
/** Defines the columns used in the playback state table. */
private object StateColumns {
const val ID = "id"
const val SONG_UID = "song_uid"
const val POSITION = "position"
const val PARENT_UID = "parent"
/** @see RawState.index */
const val INDEX = "queue_index"
/** @see RawState.positionMs */
const val POSITION = "position"
/** @see RawState.isShuffled */
const val IS_SHUFFLED = "is_shuffling"
/** @see RawState.repeatMode */
const val REPEAT_MODE = "repeat_mode"
/** @see RawState.songUid */
const val SONG_UID = "song_uid"
/** @see RawState.parentUid */
const val PARENT_UID = "parent"
}
/** Defines the columns used in the queue table. */
private object QueueColumns {
const val ID = "id"
/** @see Music.UID */
const val SONG_UID = "song_uid"
}
companion object {
const val DB_NAME = "auxio_playback_state.db"
const val DB_VERSION = 8
const val TABLE_STATE = "playback_state"
const val TABLE_QUEUE = "queue"
private const val DB_NAME = "auxio_playback_state.db"
private const val DB_VERSION = 8
private const val TABLE_STATE = "playback_state"
private const val TABLE_QUEUE = "queue"
@Volatile private var INSTANCE: PlaybackStateDatabase? = null
/** Get/Instantiate the single instance of [PlaybackStateDatabase]. */
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
*/
fun getInstance(context: Context): PlaybackStateDatabase {
val currentInstance = INSTANCE

View file

@ -34,10 +34,10 @@ import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
/**
* Core playback controller class.
* Core playback state controller class.
*
* Whereas other apps centralize the playback state around the MediaSession, Auxio does not, as
* MediaSession is poorly designed. We use our own playback state system instead.
* MediaSession is poorly designed. This class instead ful-fills this role.
*
* This should ***NOT*** be used outside of the playback module.
* - If you want to use the playback state in the UI, use
@ -46,63 +46,62 @@ import org.oxycblt.auxio.util.logW
* [org.oxycblt.auxio.playback.system.PlaybackService].
*
* Internal consumers should usually use [Callback], however the component that manages the player
* itself should instead operate as a [InternalPlayer].
* itself should instead use [InternalPlayer].
*
* All access should be done with [PlaybackStateManager.getInstance].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance()
private val callbacks = mutableListOf<Callback>()
private var internalPlayer: InternalPlayer? = null
private var pendingAction: InternalPlayer.Action? = null
/** The currently playing song. Null if there isn't one */
/** The currently playing [Song]. Null if nothing is playing. */
val song
get() = queue.getOrNull(index)
/** The parent the queue is based on, null if all songs */
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
var parent: MusicParent? = null
private set
private var _queue = mutableListOf<Song>()
private val orderedQueue = listOf<Song>()
private val shuffledQueue = listOf<Song>()
/** The current queue determined by [parent] */
/** The current queue. */
val queue
get() = _queue
/** The current position in the queue */
/** The position of the currently playing item in the queue. */
var index = -1
private set
/** The current state of the internal player. */
/** The current [InternalPlayer] state. */
var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0)
private set
/** The current [RepeatMode] */
var repeatMode = RepeatMode.NONE
set(value) {
field = value
notifyRepeatModeChanged()
}
/** Whether the queue is shuffled */
/** Whether the queue is shuffled. */
var isShuffled = false
private set
/** Whether this instance has played something or restored a state. */
/** Whether this instance has played something. */
var isInitialized = false
private set
/** The current audio session ID of the internal player. Null if no internal player present. */
/**
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
* available.
*/
val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId
/** An action that is awaiting the internal player instance to consume it. */
private var pendingAction: InternalPlayer.Action? = null
/** Add a callback to this instance. Make sure to remove it when done. */
/**
* Add a [Callback] to this instance. This can be used to receive changes in the playback
* state. Will immediately invoke [Callback] methods to initialize the instance with the
* current state.
* @param callback The [Callback] to add.
* @see Callback
*/
@Synchronized
fun addCallback(callback: Callback) {
if (isInitialized) {
@ -115,13 +114,23 @@ class PlaybackStateManager private constructor() {
callbacks.add(callback)
}
/** Remove a [Callback] bound to this instance. */
/**
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
* @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
* the first place.
* @see Callback
*/
@Synchronized
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
/** Register a [InternalPlayer] with this instance. */
/**
* Register an [InternalPlayer] for this instance. This instance will handle translating the
* current playback state into audio playback. There can be only one [InternalPlayer] at a time.
* Will invoke [InternalPlayer] methods to initialize the instance with the current state.
* @param internalPlayer The [InternalPlayer] to register. Will do nothing if already registered.
*/
@Synchronized
fun registerInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer != null) {
@ -131,15 +140,22 @@ class PlaybackStateManager private constructor() {
if (isInitialized) {
internalPlayer.loadSong(song, playerState.isPlaying)
internalPlayer.seekTo(playerState.calculateElapsedPosition())
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
// See if there's any action that has been queued.
requestAction(internalPlayer)
// Once initialized, try to synchronize with the player state it has created.
synchronizeState(internalPlayer)
}
this.internalPlayer = internalPlayer
}
/** Unregister a [InternalPlayer] with this instance. */
/**
* Unregister the [InternalPlayer] from this instance, prevent it from recieving any further
* commands.
* @param internalPlayer The [InternalPlayer] to unregister. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
@Synchronized
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
@ -152,7 +168,15 @@ class PlaybackStateManager private constructor() {
// --- PLAYING FUNCTIONS ---
/** Play a song from a parent that contains the song. */
/**
* Start new playback.
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
* @param parent The [MusicParent] to play from, or null if to play from the entire
* [MusicStore.Library].
* @param settings [Settings] required to configure the queue.
* @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle"
* configuration.
*/
@Synchronized
fun play(
song: Song?,
@ -162,26 +186,27 @@ class PlaybackStateManager private constructor() {
) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return
// Setup parent and queue
this.parent = parent
_queue = (parent?.songs ?: library.songs).toMutableList()
orderQueue(settings, shuffled, song)
// Notify components of changes
notifyNewPlayback()
notifyShuffledChanged()
internalPlayer.loadSong(this.song, true)
// Played something, so we are initialized now
isInitialized = true
}
// --- QUEUE FUNCTIONS ---
/** Go to the next song, along with doing all the checks that entails. */
/**
* Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there
* is no [Song] ahead to skip to.
*/
@Synchronized
fun next() {
val internalPlayer = internalPlayer ?: return
// Increment the index, if it cannot be incremented any further, then
// repeat and pause/resume playback depending on the setting
if (index < _queue.lastIndex) {
@ -191,7 +216,10 @@ class PlaybackStateManager private constructor() {
}
}
/** Go to the previous song, doing any checks that are needed. */
/**
* Go to the previous [Song] in the queue. Will rewind if there are no previous [Song]s
* to skip to, or if configured to do so.
*/
@Synchronized
fun prev() {
val internalPlayer = internalPlayer ?: return
@ -199,12 +227,16 @@ class PlaybackStateManager private constructor() {
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
if (internalPlayer.shouldRewindWithPrev) {
rewind()
changePlaying(true)
setPlaying(true)
} else {
gotoImpl(internalPlayer, max(index - 1, 0), true)
}
}
/**
* Play a [Song] at the given position in the queue.
* @param index The position of the [Song] in the queue to start playing.
*/
@Synchronized
fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return
@ -217,35 +249,51 @@ class PlaybackStateManager private constructor() {
internalPlayer.loadSong(song, play)
}
/** Add a [song] to the top of the queue. */
/**
* Add a [Song] to the top of the queue.
* @param song The [Song] to add.
*/
@Synchronized
fun playNext(song: Song) {
_queue.add(index + 1, song)
notifyQueueChanged()
}
/** Add a list of [songs] to the top of the queue. */
/**
* Add [Song]s to the top of the queue.
* @param songs The [Song]s to add.
*/
@Synchronized
fun playNext(songs: List<Song>) {
_queue.addAll(index + 1, songs)
notifyQueueChanged()
}
/** Add a [song] to the end of the queue. */
/**
* Add a [Song] to the end of the queue.
* @param song The [Song] to add.
*/
@Synchronized
fun addToQueue(song: Song) {
_queue.add(song)
notifyQueueChanged()
}
/** Add a list of [songs] to the end of the queue. */
/**
* Add [Song]s to the end of the queue.
* @param songs The [Song]s to add.
*/
@Synchronized
fun addToQueue(songs: List<Song>) {
_queue.addAll(songs)
notifyQueueChanged()
}
/** Move a queue item at [from] to a position at [to]. Will ignore invalid indexes. */
/**
* Move a [Song] in the queue.
* @param from The position of the [Song] to move in the queue.
* @param to The destination position in the queue.
*/
@Synchronized
fun moveQueueItem(from: Int, to: Int) {
logD("Moving item $from to position $to")
@ -253,7 +301,10 @@ class PlaybackStateManager private constructor() {
notifyQueueChanged()
}
/** Remove a queue item at [index]. Will ignore invalid indexes. */
/**
* Remove a [Song] from the queue.
* @param index The position of the [Song] to remove in the queue.
*/
@Synchronized
fun removeQueueItem(index: Int) {
logD("Removing item ${_queue[index].rawName}")
@ -261,7 +312,11 @@ class PlaybackStateManager private constructor() {
notifyQueueChanged()
}
/** Set whether this instance is [shuffled]. Updates the queue accordingly. */
/**
* (Re)shuffle or (Re)order this instance.
* @param shuffled Whether to shuffle the queue or not.
* @param settings [Settings] required to configure the queue.
*/
@Synchronized
fun reshuffle(shuffled: Boolean, settings: Settings) {
val song = song ?: return
@ -270,18 +325,26 @@ class PlaybackStateManager private constructor() {
notifyShuffledChanged()
}
/**
* Re-configure the queue.
* @param settings [Settings] required to configure the queue.
* @param shuffled Whether to shuffle the queue or not.
* @param keep the [Song] to start at in the new queue, or null if not specified.
*/
private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) {
val newIndex: Int
if (shuffled) {
// Shuffling queue, randomize the current song list and move the Song to play
// to the start.
_queue.shuffle()
if (keep != null) {
_queue.add(0, _queue.removeAt(_queue.indexOf(keep)))
}
newIndex = 0
} else {
// Ordering queue, re-sort it using the analogous parent sort configuration and
// then jump to the Song to play.
// TODO: Rework queue system to avoid having to do this
val sort =
parent.let { parent ->
when (parent) {
@ -291,7 +354,6 @@ class PlaybackStateManager private constructor() {
is Genre -> settings.detailGenreSort
}
}
sort.songsInPlace(_queue)
newIndex = keep?.let(_queue::indexOf) ?: 0
}
@ -303,6 +365,11 @@ class PlaybackStateManager private constructor() {
// --- INTERNAL PLAYER FUNCTIONS ---
/**
* Synchronize the state of this instance with the current [InternalPlayer].
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
@Synchronized
fun synchronizeState(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
@ -310,23 +377,32 @@ class PlaybackStateManager private constructor() {
return
}
val newState = internalPlayer.makeState(song?.durationMs ?: 0)
val newState = internalPlayer.getState(song?.durationMs ?: 0)
if (newState != playerState) {
playerState = newState
notifyStateChanged()
}
}
/**
* Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually.
* @param action The [InternalPlayer.Action] to perform.
*/
@Synchronized
fun startAction(action: InternalPlayer.Action) {
val internalPlayer = internalPlayer
if (internalPlayer == null || !internalPlayer.onAction(action)) {
if (internalPlayer == null || !internalPlayer.performAction(action)) {
logD("Internal player not present or did not consume action, waiting")
pendingAction = action
}
}
/** Request the stored [InternalPlayer.Action] */
/**
* Request that the pending [InternalPlayer.Action] (if any) be passed to the given
* [InternalPlayer].
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
@Synchronized
fun requestAction(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
@ -334,34 +410,45 @@ class PlaybackStateManager private constructor() {
return
}
if (pendingAction?.let(internalPlayer::onAction) == true) {
if (pendingAction?.let(internalPlayer::performAction) == true) {
logD("Pending action consumed")
pendingAction = null
}
}
/** Change the current playing state. */
fun changePlaying(isPlaying: Boolean) {
internalPlayer?.changePlaying(isPlaying)
/**
* Update whether playback is ongoing or not.
* @param isPlaying Whether playback is ongoing or not.
*/
fun setPlaying(isPlaying: Boolean) {
internalPlayer?.setPlaying(isPlaying)
}
/**
* **Seek** to a [positionMs].
* @param positionMs The position to seek to in millis.
* Seek to the given position in the currently playing [Song].
* @param positionMs The position to seek to, in milliseconds.
*/
@Synchronized
fun seekTo(positionMs: Long) {
internalPlayer?.seekTo(positionMs)
}
/** Rewind to the beginning of a song. */
/**
* Rewind to the beginning of the currently playing [Song].
*/
fun rewind() = seekTo(0)
// --- PERSISTENCE FUNCTIONS ---
/** Restore the state from the [database]. Returns if a state was restored. */
/**
* Restore the previously saved state (if any) and apply it to the playback state.
* @param database The [PlaybackStateDatabase] to load from.
* @param force Whether to force a restore regardless of the current state.
* @return If the state was restored, false otherwise.
*/
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
if (isInitialized && !force) {
// Already initialized and not forcing a restore, nothing to do.
return false
}
@ -376,10 +463,12 @@ class PlaybackStateManager private constructor() {
return false
}
synchronized(this) {
// Translate the state we have just read into a usable playback state for this
// instance.
return synchronized(this) {
// State could have changed while we were loading, so check if we were initialized
// now before applying the state.
if (state != null && (!isInitialized || force)) {
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
index = state.index
parent = state.parent
_queue = state.queue.toMutableList()
@ -390,23 +479,36 @@ class PlaybackStateManager private constructor() {
notifyRepeatModeChanged()
notifyShuffledChanged()
// Continuing playback after drastic state updates is a bad idea, so pause.
internalPlayer.loadSong(song, false)
internalPlayer.seekTo(state.positionMs)
isInitialized = true
return true
true
} else {
return false
false
}
}
}
/** Save the current state to the [database]. */
/**
* Save the current state.
* @param database The [PlaybackStateDatabase] to save the state to.
* @return If state was saved, false otherwise.
*/
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
logD("Saving state to DB")
val state = synchronized(this) { makeStateImpl() }
// Create the saved state from the current playback state.
val state = synchronized(this) {
PlaybackStateDatabase.SavedState(
index = index,
parent = parent,
queue = _queue,
positionMs = playerState.calculateElapsedPositionMs(),
isShuffled = isShuffled,
repeatMode = repeatMode) }
return try {
withContext(Dispatchers.IO) { database.write(state) }
true
@ -417,7 +519,11 @@ class PlaybackStateManager private constructor() {
}
}
/** Wipe the current state. */
/**
* Clear the current state.
* @param database The [PlaybackStateDatabase] to clear te state from
* @return If the state was cleared, false otherwise.
*/
suspend fun wipeState(database: PlaybackStateDatabase): Boolean {
logD("Wiping state")
@ -431,10 +537,14 @@ class PlaybackStateManager private constructor() {
}
}
/** Sanitize the state with [newLibrary]. */
/**
* Update the playback state to align with a new [MusicStore.Library].
* @param newLibrary The new [MusicStore.Library] that was recently loaded.
*/
@Synchronized
fun sanitize(newLibrary: MusicStore.Library) {
if (!isInitialized) {
// Nothing playing, nothing to do.
logD("Not initialized, no need to sanitize")
return
}
@ -446,9 +556,7 @@ class PlaybackStateManager private constructor() {
// While we could just save and reload the state, we instead sanitize the state
// at runtime for better performance (and to sidestep a co-routine on behalf of the caller).
val oldSongUid = song?.uid
val oldPosition = playerState.calculateElapsedPosition()
// Sanitize parent
parent =
parent?.let {
when (it) {
@ -458,33 +566,26 @@ class PlaybackStateManager private constructor() {
}
}
// Sanitize queue. Make sure we re-align the index to point to the previously playing
// Song in the queue queue.
val oldSongUid = song?.uid
_queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
while (song?.uid != oldSongUid && index > -1) {
index--
}
notifyNewPlayback()
val oldPosition = playerState.calculateElapsedPositionMs()
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
internalPlayer.loadSong(song, false)
if (index > -1) {
// Internal player may have reloaded the media item, re-seek to the previous position
seekTo(oldPosition)
}
}
private fun makeStateImpl() =
PlaybackStateDatabase.SavedState(
index = index,
parent = parent,
queue = _queue,
positionMs = playerState.calculateElapsedPosition(),
isShuffled = isShuffled,
repeatMode = repeatMode)
// --- CALLBACKS ---
private fun notifyIndexMoved() {
@ -530,36 +631,66 @@ class PlaybackStateManager private constructor() {
}
/**
* The interface for receiving updates from [PlaybackStateManager]. Add the callback to
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
* [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
*/
interface Callback {
/** Called when the index is moved, but the queue does not change. This changes the song. */
/**
* Called when the position of the currently playing item has changed, changing the
* current [Song], but no other queue attribute has changed.
* @param index The new position in the queue.
*/
fun onIndexMoved(index: Int) {}
/** Called when the queue has changed in a way that does not change the index or song. */
/**
* Called when the queue changed in a trivial manner, such as a move.
* @param queue The new queue.
*/
fun onQueueChanged(queue: List<Song>) {}
/** Called when the queue and index has changed, but the song has not changed. */
/**
* Called when the queue has changed in a non-trivial manner (such as re-shuffling),
* but the currently playing [Song] has not.
* @param index The new position in the queue.
*/
fun onQueueReworked(index: Int, queue: List<Song>) {}
/** Called when playback is changed completely, with a new index, queue, and parent. */
/**
* Called when a new playback configuration was created.
* @param index The new position in the queue.
* @param queue The new queue.
* @param parent The new [MusicParent] being played from, or null if playing from all
* songs.
*/
fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {}
/** Called when the state of the internal player changes. */
/**
* Called when the state of the [InternalPlayer] changes.
* @param state The new state of the [InternalPlayer].
*/
fun onStateChanged(state: InternalPlayer.State) {}
/** Called when the repeat mode is changed. */
/**
* Called when the [RepeatMode] changes.
* @param repeatMode The new [RepeatMode].
*/
fun onRepeatChanged(repeatMode: RepeatMode) {}
/** Called when the shuffled state is changed. */
/**
* Called when the queue's shuffle state changes. Handling the queue change itself
* should occur in [onQueueReworked],
* @param isShuffled Whether the queue is shuffled.
*/
fun onShuffledChanged(isShuffled: Boolean) {}
}
companion object {
@Volatile private var INSTANCE: PlaybackStateManager? = null
/** Get/Instantiate the single instance of [PlaybackStateManager]. */
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
*/
fun getInstance(): PlaybackStateManager {
val currentInstance = INSTANCE

View file

@ -21,15 +21,31 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
/**
* Enum that determines the playback repeat mode.
* Represents the current repeat mode of the player.
* @author Alexander Capehart (OxygenCobalt)
*/
enum class RepeatMode {
/**
* Do not repeat. Songs are played immediately, and playback is paused when the queue repeats.
*/
NONE,
/**
* Repeat the whole queue. Songs are played immediately, and playback continues when the
* queue repeats.
*/
ALL,
/**
* Repeat the current song. A Song will be continuously played until skipped. If configured,
* playback may pause when a Song repeats.
*/
TRACK;
/** Increment the mode, e.g from [NONE] to [ALL] */
/**
* Increment the mode.
* @return If [NONE], [ALL]. If [ALL], [TRACK]. If [TRACK], [NONE].
*/
fun increment() =
when (this) {
NONE -> ALL
@ -37,7 +53,10 @@ enum class RepeatMode {
TRACK -> NONE
}
/** The icon representing this particular mode. */
/**
* The integer representation of this instance.
* @see fromIntCode
*/
val icon: Int
get() =
when (this) {
@ -56,9 +75,14 @@ enum class RepeatMode {
}
companion object {
/** Convert an int [code] into an instance, or null if it isn't valid. */
fun fromIntCode(code: Int) =
when (code) {
/**
* Convert a [RepeatMode] integer representation into an instance.
* @param intCode An integer representation of a [RepeatMode]
* @return The corresponding [RepeatMode], or null if the [RepeatMode] is invalid.
* @see RepeatMode.intCode
*/
fun fromIntCode(intCode: Int) =
when (intCode) {
IntegerTable.REPEAT_MODE_NONE -> NONE
IntegerTable.REPEAT_MODE_ALL -> ALL
IntegerTable.REPEAT_MODE_TRACK -> TRACK

View file

@ -23,8 +23,7 @@ import android.content.Context
import android.content.Intent
/**
* A [BroadcastReceiver] that handles connections from bluetooth headsets, starting playback if they
* occur.
* A [BroadcastReceiver] that starts music playback when a bluetooth headset is connected.
* @author seijikun, OxygenCobalt
*/
class BluetoothHeadsetReceiver : BroadcastReceiver() {

View file

@ -25,12 +25,8 @@ import androidx.core.content.ContextCompat
import org.oxycblt.auxio.playback.state.PlaybackStateManager
/**
* Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON intent
* to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes a
* MediaSession that an app should control instead through the much better MediaController API. But
* who cares about that, we need to make sure the 3% of barely functioning TouchWiz devices running
* KitKat don't break! To prevent Auxio from not showing up at all in these apps, we declare a
* BroadcastReceiver in the manifest that hacks in this functionality.
* A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
* @author Alexander Capehart (OxygenCobalt)
*/
class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {

View file

@ -40,24 +40,14 @@ import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
/**
* The component managing the [MediaSessionCompat] instance, alongside the [NotificationComponent].
*
* Auxio does not directly rely on MediaSession, as it is extremely poorly designed. We instead just
* mirror the playback state into the media session.
*
* A component that mirrors the current playback state into the [MediaSessionCompat] and
* [NotificationComponent].
* @param context [Context] required to initialize components.
* @param callback [Callback] to forward notification updates to.
* @author Alexander Capehart (OxygenCobalt)
*/
class MediaSessionComponent(private val context: Context, private val callback: Callback) :
MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
interface Callback {
fun onPostNotification(notification: NotificationComponent?, reason: PostingReason)
}
enum class PostingReason {
METADATA,
ACTIONS
}
private val mediaSession =
MediaSessionCompat(context, context.packageName).apply {
isActive = true
@ -75,15 +65,22 @@ class MediaSessionComponent(private val context: Context, private val callback:
mediaSession.setCallback(this)
}
/**
* Forward a system media button [Intent] to the [MediaSessionCompat].
* @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward.
*/
fun handleMediaButtonIntent(intent: Intent) {
MediaButtonReceiver.handleIntent(mediaSession, intent)
}
/**
* Release this instance, closing the [MediaSessionCompat] and preventing any
* further updates to the [NotificationComponent].
*/
fun release() {
provider.release()
settings.release()
playbackManager.removeCallback(this)
mediaSession.apply {
isActive = false
release()
@ -112,91 +109,11 @@ class MediaSessionComponent(private val context: Context, private val callback:
invalidateSessionState()
}
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
if (song == null) {
mediaSession.setMetadata(emptyMetadata)
callback.onPostNotification(null, PostingReason.METADATA)
return
}
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
val title = song.resolveName(context)
val artist = song.resolveArtistContents(context)
val builder =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
song.album.resolveArtistContents(context))
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText(
METADATA_KEY_PARENT,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context))
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
song.track?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
}
song.disc?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong())
}
song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) }
// We are normally supposed to use URIs for album art, but that removes some of the
// nice things we can do like square cropping or high quality covers. Instead,
// we load a full-size bitmap into the media session and take the performance hit.
provider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
val metadata = builder.build()
mediaSession.setMetadata(metadata)
notification.updateMetadata(metadata)
callback.onPostNotification(notification, PostingReason.METADATA)
}
})
}
private fun updateQueue(queue: List<Song>) {
val queueItems =
queue.mapIndexed { i, song ->
// Since we usually have to load many songs into the queue, use the MediaStore URI
// instead of loading a bitmap.
val description =
MediaDescriptionCompat.Builder()
.setMediaId(song.uid.toString())
.setTitle(song.resolveName(context))
.setSubtitle(song.resolveArtistContents(context))
.setIconUri(song.album.coverUri)
.setMediaUri(song.uri)
.build()
MediaSessionCompat.QueueItem(description, i.toLong())
}
mediaSession.setQueue(queueItems)
}
override fun onStateChanged(state: InternalPlayer.State) {
invalidateSessionState()
notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!provider.isBusy) {
callback.onPostNotification(notification, PostingReason.ACTIONS)
callback.onPostNotification(notification)
}
}
@ -260,11 +177,11 @@ class MediaSessionComponent(private val context: Context, private val callback:
}
override fun onPlay() {
playbackManager.changePlaying(true)
playbackManager.setPlaying(true)
}
override fun onPause() {
playbackManager.changePlaying(false)
playbackManager.setPlaying(false)
}
override fun onSkipToNext() {
@ -285,7 +202,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
override fun onRewind() {
playbackManager.rewind()
playbackManager.changePlaying(true)
playbackManager.setPlaying(true)
}
override fun onSetRepeatMode(repeatMode: Int) {
@ -324,24 +241,117 @@ class MediaSessionComponent(private val context: Context, private val callback:
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
}
// --- MISC ---
// --- INTERNAL ---
/**
* Upload a new [MediaMetadataCompat] based on the current playback state to the
* [MediaSessionCompat] and [NotificationComponent].
* @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no
* [Song] is currently playing.
* @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null
* if playback is currently occuring from all songs.
*/
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
if (song == null) {
// Nothing playing, reset the MediaSession and close the notification.
mediaSession.setMetadata(emptyMetadata)
return
}
// Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
// several times.
val title = song.resolveName(context)
val artist = song.resolveArtistContents(context)
val builder =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
song.album.resolveArtistContents(context))
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText(
METADATA_KEY_PARENT,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context))
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
// These fields are nullable and so we must check first before adding them to the fields.
song.track?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
}
song.disc?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong())
}
song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) }
// We are normally supposed to use URIs for album art, but that removes some of the
// nice things we can do like square cropping or high quality covers. Instead,
// we load a full-size bitmap into the media session and take the performance hit.
provider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
val metadata = builder.build()
mediaSession.setMetadata(metadata)
notification.updateMetadata(metadata)
callback.onPostNotification(notification)
}
})
}
/**
* Upload a new queue to the [MediaSessionCompat].
* @param queue The current queue to upload.
*/
private fun updateQueue(queue: List<Song>) {
val queueItems =
queue.mapIndexed { i, song ->
val description =
MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID,
// as it's used to request a song to be played from the queue.
.setMediaId(song.uid.toString())
.setTitle(song.resolveName(context))
.setSubtitle(song.resolveArtistContents(context))
// Since we usually have to load many songs into the queue, use the
// MediaStore URI instead of loading a bitmap.
.setIconUri(song.album.coverUri)
.setMediaUri(song.uri)
.build()
// Store the item index so we can then use the analogous index in the
// playback state.
MediaSessionCompat.QueueItem(description, i.toLong())
}
mediaSession.setQueue(queueItems)
}
/** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
private fun invalidateSessionState() {
logD("Updating media session playback state")
// Note: Due to metadata updates being delayed but playback remaining ongoing, the position
// will be wonky until we can upload a duration. Again, this ties back to how I must
// aggressively batch notification updates to prevent rate-limiting.
val state =
PlaybackStateCompat.Builder()
// InternalPlayer.State handles position/state information.
playbackManager.playerState.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.index.toLong())
playbackManager.playerState.intoPlaybackState(state)
// Android 13+ relies on custom actions in the notification.
// Android 13+ leverages custom actions in the notification.
val extraAction =
// Add the secondary action (either repeat/shuffle depending on the configuration)
val secondaryAction =
when (settings.playbackNotificationAction) {
ActionMode.SHUFFLE ->
PlaybackStateCompat.CustomAction.Builder(
@ -358,20 +368,21 @@ class MediaSessionComponent(private val context: Context, private val callback:
context.getString(R.string.desc_change_repeat),
playbackManager.repeatMode.icon)
}
state.addCustomAction(secondaryAction.build())
// Add the exit action so the service can be closed
val exitAction =
PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_EXIT,
context.getString(R.string.desc_exit),
R.drawable.ic_close_24)
.build()
state.addCustomAction(extraAction.build())
state.addCustomAction(exitAction)
mediaSession.setPlaybackState(state.build())
}
/** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
private fun invalidateSecondaryAction() {
invalidateSessionState()
@ -381,15 +392,28 @@ class MediaSessionComponent(private val context: Context, private val callback:
}
if (!provider.isBusy) {
callback.onPostNotification(notification, PostingReason.ACTIONS)
callback.onPostNotification(notification)
}
}
/**
* An interface for handling changes in the notification configuration.
*/
interface Callback {
/**
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
* @param notification The new [NotificationComponent].
*/
fun onPostNotification(notification: NotificationComponent)
}
companion object {
/**
* An extended metadata key that stores the resolved name of the [MusicParent] that is
* currently being played from.
*/
const val METADATA_KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT"
private val emptyMetadata = MediaMetadataCompat.Builder().build()
private const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or

View file

@ -34,9 +34,9 @@ import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent
/**
* The unified notification for [PlaybackService]. Due to the nature of how this notification is
* used, it is *not self-sufficient*. Updates have to be delivered manually, as to prevent state
* inconsistency derived from callback order.
* The playback notification component. Due to race conditions regarding notification
* updates, this component is not self-sufficient. [MediaSessionComponent] should be used
* instead of manage it.
* @author Alexander Capehart (OxygenCobalt)
*/
@SuppressLint("RestrictedApi")
@ -66,7 +66,12 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
// --- STATE FUNCTIONS ---
/**
* Update the currently shown metadata in this notification.
* @param metadata The [MediaMetadataCompat] to display in this notification.
*/
fun updateMetadata(metadata: MediaMetadataCompat) {
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
@ -78,21 +83,28 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
} else {
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM))
}
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
}
/** Set the playing icon on the notification */
/**
* Update the playing state shown in this notification.
* @param isPlaying Whether playback should be indicated as ongoing or paused.
*/
fun updatePlaying(isPlaying: Boolean) {
mActions[2] = buildPlayPauseAction(context, isPlaying)
}
/** Update the first action to reflect the [repeatMode] given. */
/**
* Update the secondary action in this notification to show the current [RepeatMode].
* @param repeatMode The current [RepeatMode].
*/
fun updateRepeatMode(repeatMode: RepeatMode) {
mActions[0] = buildRepeatAction(context, repeatMode)
}
/** Update the first action to reflect whether the queue is shuffled or not */
/**
* Update the secondary action in this notification to show the current shuffle state.
* @param isShuffled Whether the queue is currently shuffled or not.
*/
fun updateShuffled(isShuffled: Boolean) {
mActions[0] = buildShuffleAction(context, isShuffled)
}
@ -103,8 +115,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
context: Context,
isPlaying: Boolean
): NotificationCompat.Action {
val drawableRes = if (isPlaying) R.drawable.ic_pause_24 else R.drawable.ic_play_24
val drawableRes = if (isPlaying) {
R.drawable.ic_pause_24
} else {
R.drawable.ic_play_24
}
return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
}
@ -119,9 +134,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
context: Context,
isShuffled: Boolean
): NotificationCompat.Action {
val drawableRes =
if (isShuffled) R.drawable.ic_shuffle_on_24 else R.drawable.ic_shuffle_off_24
val drawableRes = if (isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
}
return buildAction(context, PlaybackService.ACTION_INVERT_SHUFFLE, drawableRes)
}
@ -129,16 +146,13 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
context: Context,
actionName: String,
@DrawableRes iconRes: Int
): NotificationCompat.Action {
val action =
NotificationCompat.Action.Builder(
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
return action.build()
}
) =
NotificationCompat.Action.Builder(
iconRes, actionName, context.newBroadcastPendingIntent(actionName)).build()
companion object {
val CHANNEL_INFO =
/** Notification channel used by solely the playback notification. */
private val CHANNEL_INFO =
ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.lbl_playback)

View file

@ -39,8 +39,6 @@ import com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -113,8 +111,10 @@ class PlaybackService :
override fun onCreate() {
super.onCreate()
// Initialize the player component.
replayGainProcessor = ReplayGainAudioProcessor(this)
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
// Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
@ -129,62 +129,54 @@ class PlaybackService :
LibflacAudioRenderer(handler, audioListener, replayGainProcessor))
}
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
player =
ExoPlayer.Builder(this, audioRenderer)
.setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory))
// 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()
player.addListener(this)
.build().also { it.addListener(this) }
// Initialize the core service components
settings = Settings(this, this)
foregroundManager = ForegroundManager(this)
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
playbackManager.registerInternalPlayer(this)
musicStore.addCallback(this)
widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, this)
IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
addAction(ACTION_INC_REPEAT_MODE)
addAction(ACTION_INVERT_SHUFFLE)
addAction(ACTION_SKIP_PREV)
addAction(ACTION_PLAY_PAUSE)
addAction(ACTION_SKIP_NEXT)
addAction(ACTION_EXIT)
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
registerReceiver(systemReceiver, this)
}
// --- PLAYBACKSTATEMANAGER SETUP ---
registerReceiver(
systemReceiver,
IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
addAction(ACTION_INC_REPEAT_MODE)
addAction(ACTION_INVERT_SHUFFLE)
addAction(ACTION_SKIP_PREV)
addAction(ACTION_PLAY_PAUSE)
addAction(ACTION_SKIP_NEXT)
addAction(ACTION_EXIT)
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
}
)
logD("Service created")
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
// Forward system media button sent by MediaButtonReciever to MediaSessionComponent
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
mediaSessionComponent.handleMediaButtonIntent(intent)
}
return START_NOT_STICKY
}
// No binding, service is headless
// Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead.
override fun onBind(intent: Intent): IBinder? = null
// TODO: Implement task removal (Have to radically alter state saving to occur at runtime)
@ -193,13 +185,13 @@ class PlaybackService :
super.onDestroy()
foregroundManager.release()
settings.release()
// Pause just in case this destruction was unexpected.
playbackManager.changePlaying(false)
playbackManager.setPlaying(false)
playbackManager.unregisterInternalPlayer(this)
musicStore.removeCallback(this)
settings.release()
unregisterReceiver(systemReceiver)
serviceJob.cancel()
@ -224,13 +216,16 @@ class PlaybackService :
override val shouldRewindWithPrev: Boolean
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun makeState(durationMs: Long) =
override fun getState(durationMs: Long) =
InternalPlayer.State.new(
player.playWhenReady, player.isPlaying, max(min(player.currentPosition, durationMs), 0))
player.playWhenReady, player.isPlaying,
// The position value can be below zero or past the expected duration, make
// sure we handle that.
player.currentPosition.coerceAtLeast(0).coerceAtMost(durationMs))
override fun loadSong(song: Song?, play: Boolean) {
if (song == null) {
// Stop the foreground state if there's nothing to play.
// No song, stop playback and foreground state.
logD("Nothing playing, stopping playback")
player.stop()
if (openAudioEffectSession) {
@ -238,7 +233,6 @@ class PlaybackService :
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = false
}
stopAndSave()
return
}
@ -263,7 +257,7 @@ class PlaybackService :
player.seekTo(positionMs)
}
override fun changePlaying(isPlaying: Boolean) {
override fun setPlaying(isPlaying: Boolean) {
player.playWhenReady = isPlaying
}
@ -271,28 +265,29 @@ class PlaybackService :
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
var needToSynchronize =
events.containsAny(Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY)
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
needToSynchronize = true
if (player.playWhenReady) {
hasPlayed = true
}
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) && player.playWhenReady) {
// Mark that we have started playing so that the notification can now be posted.
hasPlayed = true
}
if (needToSynchronize) {
// Any change to the analogous isPlaying, isAdvancing, or positionMs values require
// us to synchronize with a new state.
if (events.containsAny(
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_POSITION_DISCONTINUITY)) {
playbackManager.synchronizeState(this)
}
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) {
// Player ended, repeat the current track if we are configured to.
if (playbackManager.repeatMode == RepeatMode.TRACK) {
playbackManager.rewind()
// May be configured to pause when we repeat a track.
if (settings.pauseOnRepeat) {
playbackManager.changePlaying(false)
playbackManager.setPlaying(false)
}
} else {
playbackManager.next()
@ -308,7 +303,8 @@ class PlaybackService :
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
// Try to find the currently playing track so we can update ReplayGainAudioProcessor
// with it.
for (group in tracks.groups) {
if (group.isSelected) {
for (i in 0 until group.length) {
@ -327,6 +323,7 @@ class PlaybackService :
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
// We now have a library, see if we have anything we need to do.
playbackManager.requestAction(this)
}
}
@ -351,11 +348,13 @@ class PlaybackService :
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
}
/** Stop the foreground state and hide the notification */
private fun stopAndSave() {
// This session has ended, so we need to reset this flag for when the next session starts.
hasPlayed = false
if (foregroundManager.tryStopForeground()) {
// Now that we have ended the foreground state (and thus music playback), we'll need
// to save the current state as it's not long until this service (and likely the whole
// app) is killed.
logD("Saving playback state")
saveScope.launch {
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
@ -363,56 +362,54 @@ class PlaybackService :
}
}
override fun onAction(action: InternalPlayer.Action): Boolean {
override fun performAction(action: InternalPlayer.Action): Boolean {
val library = musicStore.library
if (library != null) {
logD("Performing action: $action")
// No library, cannot do anything.
?: return false
when (action) {
is InternalPlayer.Action.RestoreState -> {
restoreScope.launch {
playbackManager.restoreState(
PlaybackStateDatabase.getInstance(this@PlaybackService), false)
}
}
is InternalPlayer.Action.ShuffleAll -> {
playbackManager.play(null, null, settings, true)
}
is InternalPlayer.Action.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(song, null, settings)
}
logD("Performing action: $action")
when (action) {
// Restore state -> Start a new restoreState job
is InternalPlayer.Action.RestoreState -> {
restoreScope.launch {
playbackManager.restoreState(
PlaybackStateDatabase.getInstance(this@PlaybackService), false)
}
}
// Shuffle all -> Start new playback from all songs
is InternalPlayer.Action.ShuffleAll -> {
playbackManager.play(null, null, settings, true)
}
// Open -> Try to find the Song for the given file and then play it from all songs
is InternalPlayer.Action.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(song, null, settings)
}
}
return true
}
return false
return true
}
// --- MEDIASESSIONCOMPONENT OVERRIDES ---
override fun onPostNotification(
notification: NotificationComponent?,
reason: MediaSessionComponent.PostingReason
) {
if (notification == null) {
// This case is only here if I ever need to move foreground stopping from
// the player code to the notification code.
logD("No notification, ignoring")
return
}
override fun onPostNotification(notification: NotificationComponent) {
// Do not post the notification if playback hasn't started yet. This prevents errors
// where changing a setting would cause the notification to appear in an unfriendly
// manner.
if (hasPlayed) {
logD("Updating notification [Reason: $reason]")
logD("Updating notification")
if (!foregroundManager.tryStartForeground(notification)) {
notification.post()
}
}
}
/** A [BroadcastReceiver] for receiving general playback events from the system. */
/**
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require
* an active [IntentFilter] to be registered.
*/
private inner class PlaybackReceiver : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false
@ -425,22 +422,21 @@ class PlaybackService :
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// a non-starter since both require me to display a permission prompt
// 3. Some weird internal framework thing that also handles bluetooth headsets???
//
// They should have just stopped at ACTION_HEADSET_PLUG.
// 3. Some internal framework thing that also handles bluetooth headsets
// Just use ACTION_HEADSET_PLUG.
AudioManager.ACTION_HEADSET_PLUG -> {
when (intent.getIntExtra("state", -1)) {
0 -> pauseFromPlug()
1 -> maybeResumeFromPlug()
0 -> pauseFromHeadsetPlug()
1 -> playFromHeadsetPlug()
}
initialHeadsetPlugEventHandled = true
}
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromHeadsetPlug()
// --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE ->
playbackManager.changePlaying(!playbackManager.playerState.isPlaying)
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode = playbackManager.repeatMode.increment()
ACTION_INVERT_SHUFFLE ->
@ -448,48 +444,40 @@ class PlaybackService :
ACTION_SKIP_PREV -> playbackManager.prev()
ACTION_SKIP_NEXT -> playbackManager.next()
ACTION_EXIT -> {
playbackManager.changePlaying(false)
playbackManager.setPlaying(false)
stopAndSave()
}
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
}
}
/**
* Resume from a headset plug event in the case that the quirk is enabled. This
* functionality remains a quirk for two reasons:
* 1. Automatically resuming more or less overrides all other audio streams, which is not
* that friendly
* 2. There is a bug where playback will always start when this service starts, mostly due
* to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear
* that it may not work on OEM skins that for whatever reason don't make this action fire.
*/
private fun maybeResumeFromPlug() {
if (playbackManager.song != null &&
settings.headsetAutoplay &&
private fun playFromHeadsetPlug() {
// ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached,
// which would result in unexpected playback. Work around it by dropping the first
// call to this function, which should come from that Intent.
if (settings.headsetAutoplay &&
playbackManager.song != null &&
initialHeadsetPlugEventHandled) {
logD("Device connected, resuming")
playbackManager.changePlaying(true)
playbackManager.setPlaying(true)
}
}
/** Pause from a headset plug. */
private fun pauseFromPlug() {
private fun pauseFromHeadsetPlug() {
if (playbackManager.song != null) {
logD("Device disconnected, pausing")
playbackManager.changePlaying(false)
playbackManager.setPlaying(false)
}
}
}
companion object {
private const val REWIND_THRESHOLD = 3000L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
private const val REWIND_THRESHOLD = 3000L
}
}

View file

@ -25,8 +25,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getInteger
/**
* A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when it
* is activated.
* A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when
* [isActivated] changes.
* @author Alexander Capehart (OxygenCobalt)
*/
class AnimatedMaterialButton
@ -39,15 +39,17 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
override fun setActivated(activated: Boolean) {
super.setActivated(activated)
val target = if (activated) 0.3f else 0.5f
// Activated -> Squircle (30% Radius), Inactive -> Circle (50% Radius)
val targetRadius = if (activated) 0.3f else 0.5f
if (!isLaidOut) {
updateCornerRadiusRatio(target)
// Not laid out, initialize it without animation before drawing.
updateCornerRadiusRatio(targetRadius)
return
}
animator?.cancel()
animator =
ValueAnimator.ofFloat(currentCornerRadiusRatio, target).apply {
ValueAnimator.ofFloat(currentCornerRadiusRatio, targetRadius).apply {
duration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
addUpdateListener { updateCornerRadiusRatio(animatedValue as Float) }
start()
@ -56,6 +58,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
private fun updateCornerRadiusRatio(ratio: Float) {
currentCornerRadiusRatio = ratio
// Can't reproduce the intrinsic ratio corner radius, just manually implement it with
// a dimension value.
shapeAppearanceModel = shapeAppearanceModel.withCornerSize { it.width() * ratio }
}
}

View file

@ -23,16 +23,10 @@ import android.view.View
import android.widget.FrameLayout
/**
* A class that programmatically overrides the child layout to a left-to-right (LTR) layout
* direction.
*
* The Material Design guidelines state that any components that represent a "Timeline" should
* always be LTR. In Auxio, this applies to most of the playback components. This layout in
* particular overrides the layout direction in a way that will not disrupt how other views are laid
* out.
*
* This layout can only contain one child.
*
* A [FrameLayout] that programmatically overrides the child layout to a left-to-right (LTR) layout
* direction. This is useful for "Timeline" elements that Material Design recommends be LTR in all
* cases. This layout can only contain one child, to prevent conflicts with other layout
* components.
* @author Alexander Capehart (OxygenCobalt)
*/
open class ForcedLTRFrameLayout

View file

@ -27,9 +27,8 @@ import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A wrapper around [Slider] that shows not only position and duration values, but also hacks in
* bounds checking to avoid app crashes if bad position input comes in.
*
* A wrapper around [Slider] that shows position and duration values and sanitizes input to reduce
* crashes from invalid values.
* @author Alexander Capehart (OxygenCobalt)
*/
class StyledSeekBar
@ -45,11 +44,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
binding.seekBarSlider.addOnChangeListener(this)
}
var onSeekConfirmed: ((Long) -> Unit)? = null
/** The current [Listener] attached to this instance. */
var listener: Listener? = null
/**
* The current position, in seconds. This is the current value of the SeekBar and is indicated
* by the start TextView in the layout.
* The current position, in deci-seconds(1/10th of a second). This is the current value of the
* SeekBar and is indicated by the start TextView in the layout.
*/
var positionDs: Long
get() = binding.seekBarSlider.value.toLong()
@ -57,22 +57,20 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
// Sanity check 1: Ensure that no negative values are sneaking their way into
// this component.
val from = max(value, 0)
// Sanity check 2: Ensure that this value is within the duration and will not crash
// the app, and that the user is not currently seeking (which would cause the SeekBar
// to jump around).
if (from <= durationDs && !isActivated) {
binding.seekBarSlider.value = from.toFloat()
// We would want to keep this in the callback, but the callback only fires when
// We would want to keep this in the listener, but the listener only fires when
// a value changes completely, and sometimes that does not happen with this view.
binding.seekBarPosition.text = from.formatDurationDs(true)
}
}
/**
* The current duration, in seconds. This is the end value of the SeekBar and is indicated by
* the end TextView in the layout.
* The current duration, in deci-seconds (1/10th of a second). This is the end value of the
* SeekBar and is indicated by the end TextView in the layout.
*/
var durationDs: Long
get() = binding.seekBarSlider.valueTo.toLong()
@ -81,14 +79,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
// zero, use 1 instead and disable the SeekBar.
val to = max(value, 1)
isEnabled = value > 0
// Sanity check 2: If the current value exceeds the new duration value, clamp it
// down so that we don't crash and instead have an annoying visual flicker.
if (positionDs > to) {
logD("Clamping invalid position [current: $positionDs new max: $to]")
binding.seekBarSlider.value = to.toFloat()
}
binding.seekBarSlider.valueTo = to.toFloat()
binding.seekBarDuration.text = value.formatDurationDs(false)
}
@ -102,12 +98,22 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
override fun onStopTrackingTouch(slider: Slider) {
logD("Confirming seek")
// End of seek event, send off new value to callback.
// End of seek event, send off new value to listener.
isActivated = false
onSeekConfirmed?.invoke(slider.value.toLong())
listener?.onSeekConfirmed(slider.value.toLong())
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
binding.seekBarPosition.text = value.toLong().formatDurationDs(true)
}
/** A listener for SeekBar interactions. */
interface Listener {
/**
* Called when the internal [Slider] was scrubbed to a new position, requesting that
* a seek be performed.
* @param positionDs The position to seek to, in deci-seconds (1/10th of a second).
*/
fun onSeekConfirmed(positionDs: Long)
}
}

View file

@ -70,10 +70,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/**
* Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from
* jumping around.
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable. TODO:
* Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
*/
fun expandWithRecycler(recycler: RecyclerView?) {
// TODO: Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
setExpanded(true)
recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) }
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.ui
package org.oxycblt.auxio.ui
import android.content.Context
import android.graphics.drawable.Drawable
@ -31,7 +31,10 @@ import org.oxycblt.auxio.util.systemGestureInsetsCompat
/**
* A BottomSheetBehavior that resolves several issues with the default implementation, including:
* 1.
* 1. No reasonable edge-to-edge support.
* 2. Strange corner radius behaviors.
* 3. Inability to skip half-expanded state when full-screen.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
NeoBottomSheetBehavior<V>(context, attributeSet) {
@ -73,7 +76,6 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
val layout = super.onLayoutChild(parent, child, layoutDirection)
// Don't repeat redundant initialization.
if (!initalized) {
child.apply {
@ -83,14 +85,11 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
background = createBackground(context)
setOnApplyWindowInsetsListener(::applyWindowInsets)
}
initalized = true
}
// Sometimes CoordinatorLayout doesn't dispatch window insets to us, likely due to how
// much we overload it. Ensure that we get them.
child.requestApplyInsets()
return layout
}
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.ui
package org.oxycblt.auxio.ui
import android.content.Context
import android.util.AttributeSet
@ -30,20 +30,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A behavior that automatically re-layouts and re-insets content to align with the parent layout's
* bottom sheet.
*
* Ideally, we would one day want to switch to only re-insetting content, however this comes with
* several issues::
* 1. Scroll position. I need to find a good way to save padding in order to prevent desync, as
* window insets tend to be applied after restoration.
* 2. Over scrolling. Glow scrolls will not cut it, as the bottom glow will be caught under the bar,
* and moving it above the insets will result in an incorrect glow position when the bar is not
* shown. I have to emulate stretch scrolling below Android 12 instead. However, this is also
* similarly distorted by the insets, and thus I must go further and modify the edge effect to be at
* least somewhat clamped to the insets themselves.
* 3. Touch events. Bottom sheets must always intercept touches in their bounds, or they will click
* the now overlapping content view that is only inset and not moved out of the way..
*
* bottom sheet. Ideally, we would only want to re-inset content, but that has too many issues to
* sensibly implement.
* @author Alexander Capehart (OxygenCobalt)
*/
class BottomSheetContentBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :

View file

@ -62,7 +62,6 @@ class NavigationViewModel : ViewModel() {
logD("Already navigating, not doing main action")
return
}
logD("Navigating with action $action")
_mainNavigationAction.value = action
}
@ -78,14 +77,13 @@ class NavigationViewModel : ViewModel() {
/**
* Navigate to a given [Music] item. Will do nothing if already navigating.
* @param item The [Music] to navigate to. TODO: Extend to song properties???
* @param item The [Music] to navigate to.
*/
fun exploreNavigateTo(item: Music) {
if (_exploreNavigationItem.value != null) {
logD("Already navigating, not doing explore action")
return
}
logD("Navigating to ${item.rawName}")
_exploreNavigationItem.value = item
}

View file

@ -86,8 +86,7 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
}
/**
* Delegate to automatically create and destroy an object derived from the [ViewBinding]. TODO:
* Phase this out, it's really dumb
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
* @param create Block to create the object from the [ViewBinding].
*/
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {

View file

@ -0,0 +1,63 @@
package org.oxycblt.auxio.util
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import androidx.core.database.sqlite.transaction
/**
* Query all columns in the given [SQLiteDatabase] table, running the block when the [Cursor] is
* loaded. The block will be called with [use], allowing for automatic cleanup of [Cursor]
* resources.
* @param tableName The name of the table to query all columns in.
* @param block The code block to run with the loaded [Cursor].
*/
inline fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block)
/**
* Create a table in an [SQLiteDatabase], if it does not already exist.
* @param name The name of the table to create.
* @param schema A block that adds a comma-separated list of SQL column declarations.
*/
inline fun SQLiteDatabase.createTable(name: String, schema: StringBuilder.() -> StringBuilder) {
val command = StringBuilder()
.append("CREATE TABLE IF NOT EXISTS $name(")
.schema()
.append(")")
execSQL(command.toString())
}
/**
* Safely write a list of items to an [SQLiteDatabase]. This will clear the prior list and write
* as much of the new list as possible.
* @param list The list of items to write.
* @param tableName The name of the table to write the items to.
* @param transform Code to transform an item into a corresponding [ContentValues] to the given
* table.
*/
inline fun <reified T> SQLiteDatabase.writeList(list: List<T>, tableName: String, transform: (Int, T) -> ContentValues) {
// Clear any prior items in the table.
transaction { delete(tableName, null, null) }
var transactionPosition = 0
while (transactionPosition < list.size) {
// Start at the current transaction position, if a transaction failed at any point,
// this value can be used to immediately start at the next item and continue writing
// the list without error.
var i = transactionPosition
transaction {
while (i < list.size) {
val values = transform(i, list[i])
// Increment forward now so that if this insert fails, the transactionPosition
// will still start at the next i.
i++
insert(tableName, null, values)
}
}
transactionPosition = i
logD("Wrote batch of ${T::class.simpleName} instances. " +
"Position is now at $transactionPosition")
}
}

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio.util
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
@ -29,6 +30,7 @@ import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.database.sqlite.transaction
import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment
@ -227,16 +229,6 @@ inline fun <reified T : AndroidViewModel> Fragment.androidActivityViewModels() =
inline val AndroidViewModel.context: Context
get() = getApplication()
/**
* Query all columns in the given [SQLiteDatabase] table, running the block when the [Cursor] is
* loaded. The block will be called with [use], allowing for automatic cleanup of [Cursor]
* resources.
* @param tableName The name of the table to query all columns in.
* @param block The code block to run with the loaded [Cursor].
*/
inline fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
query(tableName, null, null, null, null, null, null)?.use(block)
/**
* Get the "System Bar" [Insets] in this [WindowInsets] instance in a version-compatible manner This
* can be used to prevent [View] elements from intersecting with the navigation bars.

View file

@ -12,7 +12,7 @@
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior"
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" />

View file

@ -13,7 +13,7 @@
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior"
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" />
@ -35,7 +35,7 @@
android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior" />
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" />
<LinearLayout
android:id="@+id/queue_sheet"