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> { 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 { 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 * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
* #removeBottomSheetCallback(BottomSheetCallback)} instead * #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) { public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
if (!callbacks.contains(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) { public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
callbacks.remove(callback); callbacks.remove(callback);

View file

@ -73,7 +73,7 @@ class MainFragment :
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() 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. // navigation, navigation out of detail views, etc.
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
@ -128,7 +128,7 @@ class MainFragment :
override fun onStart() { override fun onStart() {
super.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. // our pre-draw listener our listener in onStart/onStop respectively.
requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this) requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this)
} }
@ -140,7 +140,7 @@ class MainFragment :
override fun onPreDraw(): Boolean { override fun onPreDraw(): Boolean {
// We overload CoordinatorLayout far too much to rely on any of it's typical // 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. // probably be cheap enough.
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
@ -221,7 +221,7 @@ class MainFragment :
tryHideAllSheets() 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. // every frame.
callback.invalidateEnabled() callback.invalidateEnabled()
@ -383,7 +383,7 @@ class MainFragment :
* that the back button should close first, the instance is disabled and back navigation is * that the back button should close first, the instance is disabled and back navigation is
* delegated to the system. * 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 * were no components to close, but that prevents adaptive back navigation from working on
* Android 14+, so we must do it this way. * Android 14+, so we must do it this way.
*/ */

View file

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

View file

@ -44,7 +44,7 @@ abstract class DetailAdapter(
private val listener: Listener, private val listener: Listener,
itemCallback: DiffUtil.ItemCallback<Item> itemCallback: DiffUtil.ItemCallback<Item>
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup { ) : 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) @Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
override fun getItemViewType(position: Int) = 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 // 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 // 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 } setOnApplyWindowInsetsListener { _, insets -> insets }
// We know that there will only be a fixed amount of tabs, so we manually set this // 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) .size(Size.ORIGINAL)
.transformations(SquareFrameTransform.INSTANCE)) .transformations(SquareFrameTransform.INSTANCE))
// Override the target in order to deliver the bitmap to the given // Override the target in order to deliver the bitmap to the given
// callback. // listener.
.target( .target(
onSuccess = { onSuccess = {
synchronized(this) { synchronized(this) {

View file

@ -22,11 +22,12 @@ import android.widget.Button
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
/** /**
* A basic listener for list interactions. TODO: Supply a ViewHolder on clicks (allows editable * A basic listener for list interactions.
* lists to be standardized into a listener.)
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface ClickableListListener { 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. * Called when an [Item] in the list is clicked.
* @param item The [Item] that was clicked. * @param item The [Item] that was clicked.
@ -60,15 +61,15 @@ interface SelectableListListener : ClickableListListener {
*/ */
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) { fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
viewHolder.itemView.apply { viewHolder.itemView.apply {
// Map clicks to the click callback. // Map clicks to the click listener.
setOnClickListener { onClick(item) } setOnClickListener { onClick(item) }
// Map long clicks to the selection callback. // Map long clicks to the selection listener.
setOnLongClickListener { setOnLongClickListener {
onSelect(item) onSelect(item)
true true
} }
} }
// Map the menu button to the menu opening callback. // Map the menu button to the menu opening listener.
menuButton.setOnClickListener { onOpenMenu(item, it) } 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 { interface Callback {
/** /**
* Called when the current [Library] has changed. * 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 androidx.core.database.sqlite.transaction
import java.io.File import java.io.File
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.queryAll
import org.oxycblt.auxio.util.requireBackgroundThread
/** /**
* Defines an Extractor that can load cached music. This is the first step in the music extraction * 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 { override fun populate(rawSong: Song.Raw): ExtractionResult {
val map = val map = cacheMap ?: return ExtractionResult.NONE
requireNotNull(cacheMap) {
"Must initialize this extractor before populating a raw song."
}
// For a cached raw song to be used, it must exist within the cache and have matching // 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 // 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 // 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 // means information independent of the file-system, excluding IDs and timestamps required
// to retrieve items from the cache. // to retrieve items from the cache.
val command = db.createTable(TABLE_RAW_SONGS) {
StringBuilder() append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
.append("CREATE TABLE IF NOT EXISTS $TABLE_RAW_SONGS(") append("${Columns.DATE_ADDED} LONG NOT NULL,")
.append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,") append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
.append("${Columns.DATE_ADDED} LONG NOT NULL,") append("${Columns.SIZE} LONG NOT NULL,")
.append("${Columns.DATE_MODIFIED} LONG NOT NULL,") append("${Columns.DURATION} LONG NOT NULL,")
.append("${Columns.SIZE} LONG NOT NULL,") append("${Columns.FORMAT_MIME_TYPE} STRING,")
.append("${Columns.DURATION} LONG NOT NULL,") append("${Columns.MUSIC_BRAINZ_ID} STRING,")
.append("${Columns.FORMAT_MIME_TYPE} STRING,") append("${Columns.NAME} STRING NOT NULL,")
.append("${Columns.MUSIC_BRAINZ_ID} STRING,") append("${Columns.SORT_NAME} STRING,")
.append("${Columns.NAME} STRING NOT NULL,") append("${Columns.TRACK} INT,")
.append("${Columns.SORT_NAME} STRING,") append("${Columns.DISC} INT,")
.append("${Columns.TRACK} INT,") append("${Columns.DATE} STRING,")
.append("${Columns.DISC} INT,") append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
.append("${Columns.DATE} STRING,") append("${Columns.ALBUM_NAME} STRING NOT NULL,")
.append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,") append("${Columns.ALBUM_SORT_NAME} STRING,")
.append("${Columns.ALBUM_NAME} STRING NOT NULL,") append("${Columns.ALBUM_TYPES} STRING,")
.append("${Columns.ALBUM_SORT_NAME} STRING,") append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
.append("${Columns.ALBUM_TYPES} STRING,") append("${Columns.ARTIST_NAMES} STRING,")
.append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,") append("${Columns.ARTIST_SORT_NAMES} STRING,")
.append("${Columns.ARTIST_NAMES} STRING,") append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
.append("${Columns.ARTIST_SORT_NAMES} STRING,") append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
.append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,") append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
.append("${Columns.ALBUM_ARTIST_NAMES} STRING,") append("${Columns.GENRE_NAMES} STRING")
.append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,") }
.append("${Columns.GENRE_NAMES} STRING)")
db.execSQL(command.toString())
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db) 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>) { fun write(rawSongs: List<Song.Raw>) {
val start = System.currentTimeMillis() 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) { put(Columns.SIZE, rawSong.size)
var i = position put(Columns.DURATION, rawSong.durationMs)
put(Columns.FORMAT_MIME_TYPE, rawSong.formatMimeType)
database.transaction { put(Columns.MUSIC_BRAINZ_ID, rawSong.name)
while (i < rawSongs.size) { put(Columns.NAME, rawSong.name)
val rawSong = rawSongs[i] put(Columns.SORT_NAME, rawSong.sortName)
i++
val itemData = put(Columns.TRACK, rawSong.track)
ContentValues(22).apply { put(Columns.DISC, rawSong.disc)
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId) put(Columns.DATE, rawSong.date?.toString())
put(Columns.DATE_ADDED, rawSong.dateAdded)
put(Columns.DATE_MODIFIED, rawSong.dateModified)
put(Columns.SIZE, rawSong.size) put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
put(Columns.DURATION, rawSong.durationMs) put(Columns.ALBUM_NAME, rawSong.albumName)
put(Columns.FORMAT_MIME_TYPE, rawSong.formatMimeType) put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
put(Columns.MUSIC_BRAINZ_ID, rawSong.name) put(
put(Columns.NAME, rawSong.name) Columns.ARTIST_MUSIC_BRAINZ_IDS,
put(Columns.SORT_NAME, rawSong.sortName) rawSong.artistMusicBrainzIds.toSQLMultiValue())
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
put(
Columns.ARTIST_SORT_NAMES,
rawSong.artistSortNames.toSQLMultiValue())
put(Columns.TRACK, rawSong.track) put(
put(Columns.DISC, rawSong.disc) Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
put(Columns.DATE, rawSong.date?.toString()) 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.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
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)
}
} }
// 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") 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. // Filter out music that is not music, if enabled.
if (settings.excludeNonMusic) { if (settings.excludeNonMusic) {
logD("Excluding non-music")
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" 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 * Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the
* sub-extractors before parsing the metadata itself. * 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. * successfully loaded.
*/ */
suspend fun parse(emit: suspend (Song.Raw) -> Unit) { suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
@ -131,7 +131,7 @@ class MetadataExtractor(
class Task(context: Context, private val raw: Song.Raw) { class Task(context: Context, private val raw: Song.Raw) {
// Note that we do not leverage future callbacks. This is because errors in the // 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 // (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 = private val future =
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(
context, context,

View file

@ -113,11 +113,11 @@ class Indexer private constructor() {
@Synchronized @Synchronized
fun registerCallback(callback: Callback) { fun registerCallback(callback: Callback) {
if (BuildConfig.DEBUG && this.callback != null) { if (BuildConfig.DEBUG && this.callback != null) {
logW("Callback is already registered") logW("Listener is already registered")
return return
} }
// Initialize the callback with the current state. // Initialize the listener with the current state.
val currentState = val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
callback.onIndexerStateChanged(currentState) 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. * 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 * 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) getSystemServiceCompat(PowerManager::class)
.newWakeLock( .newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService") 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. // condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver() indexerContentObserver = SystemContentObserver()
settings = Settings(this, this) settings = Settings(this, this)
@ -102,7 +102,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// De-initialize core service components first. // De-initialize core service components first.
foregroundManager.release() foregroundManager.release()
wakeLock.releaseSafe() 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. // events will not occur.
indexerContentObserver.release() indexerContentObserver.release()
settings.release() settings.release()
@ -137,8 +137,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// Wipe possibly-invalidated outdated covers // Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear() imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected // Clear invalid models from PlaybackStateManager. This is not connected
// to a callback as it is bad practice for a shared object to attach to // to a listener as it is bad practice for a shared object to attach to
// the callback system of another. // the listener system of another.
playbackManager.sanitize(newLibrary) playbackManager.sanitize(newLibrary)
} }
// Forward the new library to MusicStore to continue the update process. // 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 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 { enum class ActionMode {
/** Use a "Skip next" button for the secondary action. */
NEXT, NEXT,
/** Use a repeat mode button for the secondary action. */
REPEAT, REPEAT,
/** Use a shuffle mode button for the secondary action. */
SHUFFLE; SHUFFLE;
/**
* The integer representation of this instance.
* @see fromIntCode
*/
val intCode: Int val intCode: Int
get() = get() =
when (this) { when (this) {
@ -34,9 +45,14 @@ enum class ActionMode {
} }
companion object { companion object {
/** Convert an int [code] into an instance, or null if it isn't valid. */ /**
fun fromIntCode(code: Int) = * Convert a [ActionMode] integer representation into an instance.
when (code) { * @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_NEXT -> NEXT
IntegerTable.ACTION_MODE_REPEAT -> REPEAT IntegerTable.ACTION_MODE_REPEAT -> REPEAT
IntegerTable.ACTION_MODE_SHUFFLE -> SHUFFLE IntegerTable.ACTION_MODE_SHUFFLE -> SHUFFLE

View file

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

View file

@ -25,13 +25,12 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R 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.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
/** /**
* The coordinator layout behavior used for the playback sheet, hacking in the many fixes required * The [BaseBottomSheetBehavior] for the playback bottom sheet. This bottom sheet
* to make bottom sheets like this work.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) : 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) = override fun createBackground(context: Context) =
LayerDrawable( LayerDrawable(
arrayOf( 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 { MaterialShapeDrawable(sheetBackgroundDrawable.shapeAppearanceModel).apply {
fillColor = sheetBackgroundDrawable.fillColor fillColor = sheetBackgroundDrawable.fillColor
}, },

View file

@ -24,8 +24,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R 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.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode 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.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
@ -42,18 +43,16 @@ import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat 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) * @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 playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
// AudioEffect expects you to use startActivityForResult with the panel intent. Use // contract analogue for this intent, so the generic contract is used instead.
// the contract analogue for this since there is no built-in contract for AudioEffect. private val equalizerLauncher by lifecycleObject {
private val activityLauncher by lifecycleObject {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
// Nothing to do // Nothing to do
} }
@ -66,8 +65,9 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
binding: FragmentPlaybackPanelBinding, binding: FragmentPlaybackPanelBinding,
savedInstanceState: Bundle? savedInstanceState: Bundle?
) { ) {
// --- UI SETUP --- super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { view, insets -> binding.root.setOnApplyWindowInsetsListener { view, insets ->
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
view.updatePadding(top = bars.top, bottom = bars.bottom) view.updatePadding(top = bars.top, bottom = bars.bottom)
@ -76,39 +76,34 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
binding.playbackToolbar.apply { binding.playbackToolbar.apply {
setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) } setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) }
setOnMenuItemClickListener { setOnMenuItemClickListener(this@PlaybackPanelFragment)
handleMenuItem(it)
true
}
} }
// 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 { binding.playbackSong.apply {
isSelected = true isSelected = true
setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) } setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) }
} }
binding.playbackArtist.apply { binding.playbackArtist.apply {
isSelected = true isSelected = true
setOnClickListener { playbackModel.song.value?.let { showCurrentArtist() } } setOnClickListener { navigateToCurrentArtist() }
} }
binding.playbackAlbum.apply { binding.playbackAlbum.apply {
isSelected = true 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.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() } binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
binding.playbackSkipNext.setOnClickListener { playbackModel.next() } binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() } binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
// --- VIEWMODEL SETUP -- // --- VIEWMODEL SETUP --
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.parent, ::updateParent) collectImmediately(playbackModel.parent, ::updateParent)
collectImmediately(playbackModel.positionDs, ::updatePosition) collectImmediately(playbackModel.positionDs, ::updatePosition)
@ -118,32 +113,40 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
} }
override fun onDestroyBinding(binding: 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.playbackSong.isSelected = false
binding.playbackArtist.isSelected = false binding.playbackArtist.isSelected = false
binding.playbackAlbum.isSelected = false binding.playbackAlbum.isSelected = false
} }
private fun handleMenuItem(item: MenuItem) { override fun onMenuItemClick(item: MenuItem) =
when (item.itemId) { when (item.itemId) {
R.id.action_open_equalizer -> { R.id.action_open_equalizer -> {
// Launch the system equalizer app, if possible.
val equalizerIntent = val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
// Provide audio session ID so equalizer can show options for this app
// in particular.
.putExtra( .putExtra(
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId) 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) .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
try { try {
activityLauncher.launch(equalizerIntent) equalizerLauncher.launch(equalizerIntent)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
requireContext().showToast(R.string.err_no_app) requireContext().showToast(R.string.err_no_app)
} }
true
} }
R.id.action_go_artist -> { R.id.action_go_artist -> {
showCurrentArtist() navigateToCurrentArtist()
true
} }
R.id.action_go_album -> { R.id.action_go_album -> {
showCurrentAlbum() navigateToCurrentAlbum()
true
} }
R.id.action_song_detail -> { R.id.action_song_detail -> {
playbackModel.song.value?.let { song -> playbackModel.song.value?.let { song ->
@ -151,8 +154,13 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
MainNavigationAction.Directions( MainNavigationAction.Directions(
MainFragmentDirections.actionShowDetails(song.uid))) MainFragmentDirections.actionShowDetails(song.uid)))
} }
true
} }
else -> false
} }
override fun onSeekConfirmed(positionDs: Long) {
playbackModel.seekTo(positionDs)
} }
private fun updateSong(song: Song?) { private fun updateSong(song: Song?) {
@ -192,12 +200,14 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
requireBinding().playbackShuffle.isActivated = isShuffled 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 val song = playbackModel.song.value ?: return
navModel.exploreNavigateTo(song.artists) navModel.exploreNavigateTo(song.artists)
} }
private fun showCurrentAlbum() { /** Navigate to the currently playing [Song]'s albums. */
private fun navigateToCurrentAlbum() {
val song = playbackModel.song.value ?: return val song = playbackModel.song.value ?: return
navModel.exploreNavigateTo(song.album) navModel.exploreNavigateTo(song.album)
} }

View file

@ -20,52 +20,65 @@ package org.oxycblt.auxio.playback
import android.text.format.DateUtils import android.text.format.DateUtils
import org.oxycblt.auxio.util.logD 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) 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) 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) 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) 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) 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 --:-- * @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0. * will be returned if the second value is 0.
*/ */
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed) 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 --:-- * @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0. * will be returned if the second value is 0.
*/ */
fun Long.formatDurationDs(isElapsed: Boolean) = dsToSecs().formatDurationSecs(isElapsed) 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 --:-- * @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
* will be returned if the second value is 0. * will be returned if the second value is 0.
*/ */
fun Long.formatDurationSecs(isElapsed: Boolean): String { fun Long.formatDurationSecs(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) { if (!isElapsed && this == 0L) {
logD("Non-elapsed duration is zero, using --:--") // Non-elapsed duration is zero, return default value.
return "--:--" return "--:--"
} }
var durationString = DateUtils.formatElapsedTime(this) var durationString = DateUtils.formatElapsedTime(this)
// Remove trailing zero values [i.e 01:42]. This is primarily for aesthetics.
// If the duration begins with a excess zero [e.g 01:42], then cut it off.
if (durationString[0] == '0') { if (durationString[0] == '0') {
durationString = durationString.slice(1 until durationString.length) durationString = durationString.slice(1 until durationString.length)
} }
return durationString return durationString
} }

View file

@ -34,284 +34,60 @@ import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
/** /**
* The ViewModel that provides a UI frontend for [PlaybackStateManager]. * The ViewModel that provides a safe 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.**
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Queue additions without a song should map to playing selected
*/ */
class PlaybackViewModel(application: Application) : class PlaybackViewModel(application: Application) :
AndroidViewModel(application), PlaybackStateManager.Callback { AndroidViewModel(application), PlaybackStateManager.Callback {
private val settings = Settings(application) private val settings = Settings(application)
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private var lastPositionJob: Job? = null
private val _song = MutableStateFlow<Song?>(null) private val _song = MutableStateFlow<Song?>(null)
/** The currently playing song. */
/** The current song. */
val song: StateFlow<Song?> val song: StateFlow<Song?>
get() = _song get() = _song
private val _parent = MutableStateFlow<MusicParent?>(null) private val _parent = MutableStateFlow<MusicParent?>(null)
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
/** The current model that is being played from, such as an [Album] or [Artist] */
val parent: StateFlow<MusicParent?> = _parent val parent: StateFlow<MusicParent?> = _parent
private val _isPlaying = MutableStateFlow(false) private val _isPlaying = MutableStateFlow(false)
/** Whether playback is ongoing or paused.*/
val isPlaying: StateFlow<Boolean> val isPlaying: StateFlow<Boolean>
get() = _isPlaying get() = _isPlaying
private val _positionDs = MutableStateFlow(0L) private val _positionDs = MutableStateFlow(0L)
/** The current position, in deci-seconds (1/10th of a second). */
/** The current playback position, in *deci-seconds* */
val positionDs: StateFlow<Long> val positionDs: StateFlow<Long>
get() = _positionDs get() = _positionDs
private val _repeatMode = MutableStateFlow(RepeatMode.NONE) private val _repeatMode = MutableStateFlow(RepeatMode.NONE)
/** The current [RepeatMode]. */
/** The current repeat mode, see [RepeatMode] for more information */
val repeatMode: StateFlow<RepeatMode> val repeatMode: StateFlow<RepeatMode>
get() = _repeatMode get() = _repeatMode
private val _isShuffled = MutableStateFlow(false) private val _isShuffled = MutableStateFlow(false)
/** Whether the queue is shuffled or not. */
val isShuffled: StateFlow<Boolean> val isShuffled: StateFlow<Boolean>
get() = _isShuffled 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) 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?> val artistPlaybackPickerSong: StateFlow<Song?>
get() = _artistPlaybackPickerSong get() = _artistPlaybackPickerSong
/**
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
* available.
*/
val currentAudioSessionId: Int?
get() = playbackManager.currentAudioSessionId
init { init {
playbackManager.addCallback(this) 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() { override fun onCleared() {
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
} }
@ -327,14 +103,16 @@ class PlaybackViewModel(application: Application) :
override fun onStateChanged(state: InternalPlayer.State) { override fun onStateChanged(state: InternalPlayer.State) {
_isPlaying.value = state.isPlaying _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?.cancel()
lastPositionJob = lastPositionJob =
viewModelScope.launch { viewModelScope.launch {
while (true) { while (true) {
_positionDs.value = state.calculateElapsedPosition().msToDs() _positionDs.value = state.calculateElapsedPositionMs().msToDs()
// Wait a deci-second for the next position tick.
delay(100) delay(100)
} }
} }
@ -347,4 +125,297 @@ class PlaybackViewModel(application: Application) :
override fun onRepeatChanged(repeatMode: RepeatMode) { override fun onRepeatChanged(repeatMode: RepeatMode) {
_repeatMode.value = 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 androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R 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.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels 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) // If requested, scroll to a new item (occurs when the index moves)
val scrollTo = queueModel.scrollTo val scrollTo = queueModel.scrollTo
if (scrollTo != null) { if (scrollTo != null) {
// Do not scroll to indices that are not in the currently visible range. // Do not scroll to indices that are in the currently visible range. As that would
// This prevents the queue from jumping around when the user is trying to // lead to the queue jumping around every time goto is called.
// navigate the queue.
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition() val start = lmm.findFirstCompletelyVisibleItemPosition()
val end = lmm.findLastCompletelyVisibleItemPosition() val end = lmm.findLastCompletelyVisibleItemPosition()

View file

@ -22,51 +22,88 @@ import android.os.SystemClock
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import org.oxycblt.auxio.music.Song 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 { interface InternalPlayer {
/** The audio session ID of the player instance. */ /** The ID of the audio session started by this instance. */
val audioSessionId: Int 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 val shouldRewindWithPrev: Boolean
fun makeState(durationMs: Long): State /**
* Load a new [Song] into the internal player.
/** Called when a new song should be loaded into the 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) fun loadSong(song: Song?, play: Boolean)
/** Seek to [positionMs] in the player. */ /**
fun seekTo(positionMs: Long) * Called when an [Action] has been queued and this [InternalPlayer] is available to handle it.
* @param action The [Action] to perform.
/** Called when the playing state needs to be changed. */ * @return true if the action was handled, false otherwise.
fun changePlaying(isPlaying: Boolean) */
fun performAction(action: Action): Boolean
/** /**
* Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the * Get a [State] corresponding to the current player state.
* action was consumed, false otherwise. * @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 class State
private constructor( private constructor(
/** /** Whether the player is actively playing audio or set to play audio in the future. */
* Whether the user has actually chosen to play this audio. The player might not actually be
* playing at this time.
*/
val isPlaying: Boolean, 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, private val isAdvancing: Boolean,
/** The initial position at update time. */ /** The position when this instance was created, in milliseconds. */
private val initPositionMs: Long, 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 private val creationTime: Long
) { ) {
/** /**
* Calculate the estimated position that the player is now at. If the player's position is * Calculate the "real" playback position this instance contains, in milliseconds.
* not advancing, this will be the initial position. Otherwise, this will be the position * @return If paused, the original position will be returned. Otherwise, it will be
* plus the elapsed time since this state was uploaded. * the original position plus the time elapsed since this state was created.
*/ */
fun calculateElapsedPosition() = fun calculateElapsedPositionMs() =
if (isAdvancing) { if (isAdvancing) {
initPositionMs + (SystemClock.elapsedRealtime() - creationTime) initPositionMs + (SystemClock.elapsedRealtime() - creationTime)
} else { } else {
@ -75,7 +112,11 @@ interface InternalPlayer {
initPositionMs 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 = fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
builder.setState( builder.setState(
// State represents the user's preference, not the actual player state. // State represents the user's preference, not the actual player state.
@ -94,8 +135,8 @@ interface InternalPlayer {
}, },
creationTime) creationTime)
// Equality ignores the creation time to prevent functionally // Equality ignores the creation time to prevent functionally identical states
// identical states from being equal. // from being non-equal.
override fun equals(other: Any?) = override fun equals(other: Any?) =
other is State && other is State &&
@ -111,21 +152,20 @@ interface InternalPlayer {
} }
companion object { 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) = fun new(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
State( State(
isPlaying, isPlaying,
// Minor sanity check: Make sure that advancing can't occur if the // Minor sanity check: Make sure that advancing can't occur if already paused.
// main playing value is paused.
isPlaying && isAdvancing, isPlaying && isAdvancing,
positionMs, positionMs,
SystemClock.elapsedRealtime()) 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.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import android.provider.BaseColumns
import androidx.core.database.sqlite.transaction import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.queryAll
import org.oxycblt.auxio.util.requireBackgroundThread
/** /**
* A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists. * A [SQLiteDatabase] that persists the current playback state for future app lifecycles.
* But that would needlessly bloat my app and has crippling bugs.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackStateDatabase private constructor(context: Context) : class PlaybackStateDatabase private constructor(context: Context) :
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) { override fun onCreate(db: SQLiteDatabase) {
createTable(db, TABLE_STATE) // Here, we have to split the database into two tables. One contains the queue with
createTable(db, TABLE_QUEUE) // 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) 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 --- // --- 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? { fun read(library: MusicStore.Library): SavedState? {
requireBackgroundThread() 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 rawState = readRawState() ?: return null
val queue = readQueue(library) val queue = readQueue(library)
// Correct the index to match up with a queue that has possibly been shortened due to
// Correct the index to match up with a possibly shortened queue (file removals/changes) // song removals.
var actualIndex = rawState.index var actualIndex = rawState.index
while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) { while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) {
actualIndex-- 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) } val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
return SavedState( return SavedState(
index = actualIndex, index = actualIndex,
parent = parent, parent = parent,
@ -113,22 +99,19 @@ class PlaybackStateDatabase private constructor(context: Context) :
isShuffled = rawState.isShuffled) isShuffled = rawState.isShuffled)
} }
private fun readRawState(): RawState? { private fun readRawState() =
return readableDatabase.queryAll(TABLE_STATE) { cursor -> readableDatabase.queryAll(TABLE_STATE) { cursor ->
if (cursor.count == 0) { if (!cursor.moveToFirst()) {
// Empty, nothing to do.
return@queryAll null return@queryAll null
} }
val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX) val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX)
val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION) val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION)
val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE) val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE)
val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED) val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED)
val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID) val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID)
val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID) val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID)
cursor.moveToFirst()
RawState( RawState(
index = cursor.getInt(indexIndex), index = cursor.getInt(indexIndex),
positionMs = cursor.getLong(posIndex), positionMs = cursor.getLong(posIndex),
@ -139,15 +122,10 @@ class PlaybackStateDatabase private constructor(context: Context) :
?: return@queryAll null, ?: return@queryAll null,
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString)) 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>() val queue = mutableListOf<Song>()
readableDatabase.queryAll(TABLE_QUEUE) { cursor -> readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
if (cursor.count == 0) return@queryAll
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID) val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue 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") logD("Successfully read queue of ${queue.size} songs")
return queue 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?) { fun write(state: SavedState?) {
requireBackgroundThread() 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) { 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 = val rawState =
RawState( RawState(
index = state.index, index = state.index,
@ -174,15 +156,14 @@ class PlaybackStateDatabase private constructor(context: Context) :
isShuffled = state.isShuffled, isShuffled = state.isShuffled,
songUid = state.queue[state.index].uid, songUid = state.queue[state.index].uid,
parentUid = state.parent?.uid) parentUid = state.parent?.uid)
writeRawState(rawState) writeRawState(rawState)
writeQueue(state.queue) writeQueue(state.queue)
logD("Wrote state")
} else { } else {
writeRawState(null) writeRawState(null)
writeQueue(null) writeQueue(null)
logD("Cleared state")
} }
logD("Wrote state to database")
} }
private fun writeRawState(rawState: RawState?) { private fun writeRawState(rawState: RawState?) {
@ -192,7 +173,7 @@ class PlaybackStateDatabase private constructor(context: Context) :
if (rawState != null) { if (rawState != null) {
val stateData = val stateData =
ContentValues(7).apply { ContentValues(7).apply {
put(StateColumns.ID, 0) put(BaseColumns._ID, 0)
put(StateColumns.SONG_UID, rawState.songUid.toString()) put(StateColumns.SONG_UID, rawState.songUid.toString())
put(StateColumns.POSITION, rawState.positionMs) put(StateColumns.POSITION, rawState.positionMs)
put(StateColumns.PARENT_UID, rawState.parentUid?.toString()) 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>?) { private fun writeQueue(queue: List<Song>?) {
val database = writableDatabase writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song ->
database.transaction { delete(TABLE_QUEUE, null, null) } ContentValues(2).apply {
put(BaseColumns._ID, i)
logD("Wiped queue db") put(QueueColumns.SONG_UID, song.uid.toString())
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")
} }
} }
} }
/**
* 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( data class SavedState(
val index: Int, val index: Int,
val queue: List<Song>, val queue: List<Song>,
@ -254,40 +215,63 @@ class PlaybackStateDatabase private constructor(context: Context) :
val isShuffled: Boolean val isShuffled: Boolean
) )
/**
* A lower-level form of [SavedState] that contains additional information to create
* a more reliable restoration process.
*/
private data class RawState( private data class RawState(
/** @see SavedState.index */
val index: Int, val index: Int,
/** @see SavedState.positionMs */
val positionMs: Long, val positionMs: Long,
/** @see SavedState.repeatMode */
val repeatMode: RepeatMode, val repeatMode: RepeatMode,
/** @see SavedState.isShuffled */
val isShuffled: Boolean, 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, val songUid: Music.UID,
/** @see SavedState.parent */
val parentUid: Music.UID? val parentUid: Music.UID?
) )
/** Defines the columns used in the playback state table. */
private object StateColumns { private object StateColumns {
const val ID = "id" /** @see RawState.index */
const val SONG_UID = "song_uid"
const val POSITION = "position"
const val PARENT_UID = "parent"
const val INDEX = "queue_index" const val INDEX = "queue_index"
/** @see RawState.positionMs */
const val POSITION = "position"
/** @see RawState.isShuffled */
const val IS_SHUFFLED = "is_shuffling" const val IS_SHUFFLED = "is_shuffling"
/** @see RawState.repeatMode */
const val REPEAT_MODE = "repeat_mode" 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 { private object QueueColumns {
const val ID = "id" /** @see Music.UID */
const val SONG_UID = "song_uid" const val SONG_UID = "song_uid"
} }
companion object { companion object {
const val DB_NAME = "auxio_playback_state.db" private const val DB_NAME = "auxio_playback_state.db"
const val DB_VERSION = 8 private const val DB_VERSION = 8
private const val TABLE_STATE = "playback_state"
const val TABLE_STATE = "playback_state" private const val TABLE_QUEUE = "queue"
const val TABLE_QUEUE = "queue"
@Volatile private var INSTANCE: PlaybackStateDatabase? = null @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 { fun getInstance(context: Context): PlaybackStateDatabase {
val currentInstance = INSTANCE val currentInstance = INSTANCE

View file

@ -34,10 +34,10 @@ import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW 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 * 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. * This should ***NOT*** be used outside of the playback module.
* - If you want to use the playback state in the UI, use * - 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]. * [org.oxycblt.auxio.playback.system.PlaybackService].
* *
* Internal consumers should usually use [Callback], however the component that manages the player * 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]. * All access should be done with [PlaybackStateManager.getInstance].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
private var internalPlayer: InternalPlayer? = null 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 val song
get() = queue.getOrNull(index) get() = queue.getOrNull(index)
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
/** The parent the queue is based on, null if all songs */
var parent: MusicParent? = null var parent: MusicParent? = null
private set private set
private var _queue = mutableListOf<Song>() private var _queue = mutableListOf<Song>()
/** The current queue. */
private val orderedQueue = listOf<Song>()
private val shuffledQueue = listOf<Song>()
/** The current queue determined by [parent] */
val queue val queue
get() = _queue get() = _queue
/** The position of the currently playing item in the queue. */
/** The current position in the queue */
var index = -1 var index = -1
private set private set
/** The current [InternalPlayer] state. */
/** The current state of the internal player. */
var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0) var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0)
private set private set
/** The current [RepeatMode] */ /** The current [RepeatMode] */
var repeatMode = RepeatMode.NONE var repeatMode = RepeatMode.NONE
set(value) { set(value) {
field = value field = value
notifyRepeatModeChanged() notifyRepeatModeChanged()
} }
/** Whether the queue is shuffled. */
/** Whether the queue is shuffled */
var isShuffled = false var isShuffled = false
private set private set
/** Whether this instance has played something. */
/** Whether this instance has played something or restored a state. */
var isInitialized = false var isInitialized = false
private set 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? val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId 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 @Synchronized
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
if (isInitialized) { if (isInitialized) {
@ -115,13 +114,23 @@ class PlaybackStateManager private constructor() {
callbacks.add(callback) 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 @Synchronized
fun removeCallback(callback: Callback) { fun removeCallback(callback: Callback) {
callbacks.remove(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 @Synchronized
fun registerInternalPlayer(internalPlayer: InternalPlayer) { fun registerInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer != null) { if (BuildConfig.DEBUG && this.internalPlayer != null) {
@ -131,15 +140,22 @@ class PlaybackStateManager private constructor() {
if (isInitialized) { if (isInitialized) {
internalPlayer.loadSong(song, playerState.isPlaying) 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) requestAction(internalPlayer)
// Once initialized, try to synchronize with the player state it has created.
synchronizeState(internalPlayer) synchronizeState(internalPlayer)
} }
this.internalPlayer = 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 @Synchronized
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
@ -152,7 +168,15 @@ class PlaybackStateManager private constructor() {
// --- PLAYING FUNCTIONS --- // --- 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 @Synchronized
fun play( fun play(
song: Song?, song: Song?,
@ -162,26 +186,27 @@ class PlaybackStateManager private constructor() {
) { ) {
val internalPlayer = internalPlayer ?: return val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return val library = musicStore.library ?: return
// Setup parent and queue
this.parent = parent this.parent = parent
_queue = (parent?.songs ?: library.songs).toMutableList() _queue = (parent?.songs ?: library.songs).toMutableList()
orderQueue(settings, shuffled, song) orderQueue(settings, shuffled, song)
// Notify components of changes
notifyNewPlayback() notifyNewPlayback()
notifyShuffledChanged() notifyShuffledChanged()
internalPlayer.loadSong(this.song, true) internalPlayer.loadSong(this.song, true)
// Played something, so we are initialized now
isInitialized = true isInitialized = true
} }
// --- QUEUE FUNCTIONS --- // --- 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 @Synchronized
fun next() { fun next() {
val internalPlayer = internalPlayer ?: return val internalPlayer = internalPlayer ?: return
// Increment the index, if it cannot be incremented any further, then // Increment the index, if it cannot be incremented any further, then
// repeat and pause/resume playback depending on the setting // repeat and pause/resume playback depending on the setting
if (index < _queue.lastIndex) { 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 @Synchronized
fun prev() { fun prev() {
val internalPlayer = internalPlayer ?: return 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 enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
if (internalPlayer.shouldRewindWithPrev) { if (internalPlayer.shouldRewindWithPrev) {
rewind() rewind()
changePlaying(true) setPlaying(true)
} else { } else {
gotoImpl(internalPlayer, max(index - 1, 0), true) 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 @Synchronized
fun goto(index: Int) { fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return val internalPlayer = internalPlayer ?: return
@ -217,35 +249,51 @@ class PlaybackStateManager private constructor() {
internalPlayer.loadSong(song, play) 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 @Synchronized
fun playNext(song: Song) { fun playNext(song: Song) {
_queue.add(index + 1, song) _queue.add(index + 1, song)
notifyQueueChanged() 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 @Synchronized
fun playNext(songs: List<Song>) { fun playNext(songs: List<Song>) {
_queue.addAll(index + 1, songs) _queue.addAll(index + 1, songs)
notifyQueueChanged() 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 @Synchronized
fun addToQueue(song: Song) { fun addToQueue(song: Song) {
_queue.add(song) _queue.add(song)
notifyQueueChanged() 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 @Synchronized
fun addToQueue(songs: List<Song>) { fun addToQueue(songs: List<Song>) {
_queue.addAll(songs) _queue.addAll(songs)
notifyQueueChanged() 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 @Synchronized
fun moveQueueItem(from: Int, to: Int) { fun moveQueueItem(from: Int, to: Int) {
logD("Moving item $from to position $to") logD("Moving item $from to position $to")
@ -253,7 +301,10 @@ class PlaybackStateManager private constructor() {
notifyQueueChanged() 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 @Synchronized
fun removeQueueItem(index: Int) { fun removeQueueItem(index: Int) {
logD("Removing item ${_queue[index].rawName}") logD("Removing item ${_queue[index].rawName}")
@ -261,7 +312,11 @@ class PlaybackStateManager private constructor() {
notifyQueueChanged() 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 @Synchronized
fun reshuffle(shuffled: Boolean, settings: Settings) { fun reshuffle(shuffled: Boolean, settings: Settings) {
val song = song ?: return val song = song ?: return
@ -270,18 +325,26 @@ class PlaybackStateManager private constructor() {
notifyShuffledChanged() 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?) { private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) {
val newIndex: Int val newIndex: Int
if (shuffled) { if (shuffled) {
// Shuffling queue, randomize the current song list and move the Song to play
// to the start.
_queue.shuffle() _queue.shuffle()
if (keep != null) { if (keep != null) {
_queue.add(0, _queue.removeAt(_queue.indexOf(keep))) _queue.add(0, _queue.removeAt(_queue.indexOf(keep)))
} }
newIndex = 0 newIndex = 0
} else { } 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 = val sort =
parent.let { parent -> parent.let { parent ->
when (parent) { when (parent) {
@ -291,7 +354,6 @@ class PlaybackStateManager private constructor() {
is Genre -> settings.detailGenreSort is Genre -> settings.detailGenreSort
} }
} }
sort.songsInPlace(_queue) sort.songsInPlace(_queue)
newIndex = keep?.let(_queue::indexOf) ?: 0 newIndex = keep?.let(_queue::indexOf) ?: 0
} }
@ -303,6 +365,11 @@ class PlaybackStateManager private constructor() {
// --- INTERNAL PLAYER FUNCTIONS --- // --- 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 @Synchronized
fun synchronizeState(internalPlayer: InternalPlayer) { fun synchronizeState(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
@ -310,23 +377,32 @@ class PlaybackStateManager private constructor() {
return return
} }
val newState = internalPlayer.makeState(song?.durationMs ?: 0) val newState = internalPlayer.getState(song?.durationMs ?: 0)
if (newState != playerState) { if (newState != playerState) {
playerState = newState playerState = newState
notifyStateChanged() notifyStateChanged()
} }
} }
/**
* Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually.
* @param action The [InternalPlayer.Action] to perform.
*/
@Synchronized @Synchronized
fun startAction(action: InternalPlayer.Action) { fun startAction(action: InternalPlayer.Action) {
val internalPlayer = internalPlayer 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") logD("Internal player not present or did not consume action, waiting")
pendingAction = action 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 @Synchronized
fun requestAction(internalPlayer: InternalPlayer) { fun requestAction(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
@ -334,34 +410,45 @@ class PlaybackStateManager private constructor() {
return return
} }
if (pendingAction?.let(internalPlayer::onAction) == true) { if (pendingAction?.let(internalPlayer::performAction) == true) {
logD("Pending action consumed") logD("Pending action consumed")
pendingAction = null pendingAction = null
} }
} }
/** Change the current playing state. */ /**
fun changePlaying(isPlaying: Boolean) { * Update whether playback is ongoing or not.
internalPlayer?.changePlaying(isPlaying) * @param isPlaying Whether playback is ongoing or not.
*/
fun setPlaying(isPlaying: Boolean) {
internalPlayer?.setPlaying(isPlaying)
} }
/** /**
* **Seek** to a [positionMs]. * Seek to the given position in the currently playing [Song].
* @param positionMs The position to seek to in millis. * @param positionMs The position to seek to, in milliseconds.
*/ */
@Synchronized @Synchronized
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
internalPlayer?.seekTo(positionMs) internalPlayer?.seekTo(positionMs)
} }
/** Rewind to the beginning of a song. */ /**
* Rewind to the beginning of the currently playing [Song].
*/
fun rewind() = seekTo(0) fun rewind() = seekTo(0)
// --- PERSISTENCE FUNCTIONS --- // --- 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 { suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
if (isInitialized && !force) { if (isInitialized && !force) {
// Already initialized and not forcing a restore, nothing to do.
return false return false
} }
@ -376,10 +463,12 @@ class PlaybackStateManager private constructor() {
return false 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)) { if (state != null && (!isInitialized || force)) {
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
index = state.index index = state.index
parent = state.parent parent = state.parent
_queue = state.queue.toMutableList() _queue = state.queue.toMutableList()
@ -390,23 +479,36 @@ class PlaybackStateManager private constructor() {
notifyRepeatModeChanged() notifyRepeatModeChanged()
notifyShuffledChanged() notifyShuffledChanged()
// Continuing playback after drastic state updates is a bad idea, so pause.
internalPlayer.loadSong(song, false) internalPlayer.loadSong(song, false)
internalPlayer.seekTo(state.positionMs) internalPlayer.seekTo(state.positionMs)
isInitialized = true isInitialized = true
return true true
} else { } 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 { suspend fun saveState(database: PlaybackStateDatabase): Boolean {
logD("Saving state to DB") 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 { return try {
withContext(Dispatchers.IO) { database.write(state) } withContext(Dispatchers.IO) { database.write(state) }
true 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 { suspend fun wipeState(database: PlaybackStateDatabase): Boolean {
logD("Wiping state") 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 @Synchronized
fun sanitize(newLibrary: MusicStore.Library) { fun sanitize(newLibrary: MusicStore.Library) {
if (!isInitialized) { if (!isInitialized) {
// Nothing playing, nothing to do.
logD("Not initialized, no need to sanitize") logD("Not initialized, no need to sanitize")
return return
} }
@ -446,9 +556,7 @@ class PlaybackStateManager private constructor() {
// While we could just save and reload the state, we instead sanitize the state // 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). // at runtime for better performance (and to sidestep a co-routine on behalf of the caller).
val oldSongUid = song?.uid // Sanitize parent
val oldPosition = playerState.calculateElapsedPosition()
parent = parent =
parent?.let { parent?.let {
when (it) { 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) } _queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
while (song?.uid != oldSongUid && index > -1) { while (song?.uid != oldSongUid && index > -1) {
index-- index--
} }
notifyNewPlayback() notifyNewPlayback()
val oldPosition = playerState.calculateElapsedPositionMs()
// Continuing playback while also possibly doing drastic state updates is // Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause. // a bad idea, so pause.
internalPlayer.loadSong(song, false) internalPlayer.loadSong(song, false)
if (index > -1) { if (index > -1) {
// Internal player may have reloaded the media item, re-seek to the previous position // Internal player may have reloaded the media item, re-seek to the previous position
seekTo(oldPosition) seekTo(oldPosition)
} }
} }
private fun makeStateImpl() =
PlaybackStateDatabase.SavedState(
index = index,
parent = parent,
queue = _queue,
positionMs = playerState.calculateElapsedPosition(),
isShuffled = isShuffled,
repeatMode = repeatMode)
// --- CALLBACKS --- // --- CALLBACKS ---
private fun notifyIndexMoved() { 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]. * [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
*/ */
interface Callback { 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) {} 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>) {} 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>) {} 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?) {} 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) {} 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) {} 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) {} fun onShuffledChanged(isShuffled: Boolean) {}
} }
companion object { companion object {
@Volatile private var INSTANCE: PlaybackStateManager? = null @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 { fun getInstance(): PlaybackStateManager {
val currentInstance = INSTANCE val currentInstance = INSTANCE

View file

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

View file

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

View file

@ -25,12 +25,8 @@ import androidx.core.content.ContextCompat
import org.oxycblt.auxio.playback.state.PlaybackStateManager 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 * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
* to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes a * @author Alexander Capehart (OxygenCobalt)
* 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.
*/ */
class MediaButtonReceiver : BroadcastReceiver() { class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { 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 import org.oxycblt.auxio.util.logD
/** /**
* The component managing the [MediaSessionCompat] instance, alongside the [NotificationComponent]. * A component that mirrors the current playback state into the [MediaSessionCompat] and
* * [NotificationComponent].
* Auxio does not directly rely on MediaSession, as it is extremely poorly designed. We instead just * @param context [Context] required to initialize components.
* mirror the playback state into the media session. * @param callback [Callback] to forward notification updates to.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MediaSessionComponent(private val context: Context, private val callback: Callback) : class MediaSessionComponent(private val context: Context, private val callback: Callback) :
MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback { MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
interface Callback {
fun onPostNotification(notification: NotificationComponent?, reason: PostingReason)
}
enum class PostingReason {
METADATA,
ACTIONS
}
private val mediaSession = private val mediaSession =
MediaSessionCompat(context, context.packageName).apply { MediaSessionCompat(context, context.packageName).apply {
isActive = true isActive = true
@ -75,15 +65,22 @@ class MediaSessionComponent(private val context: Context, private val callback:
mediaSession.setCallback(this) 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) { fun handleMediaButtonIntent(intent: Intent) {
MediaButtonReceiver.handleIntent(mediaSession, intent) MediaButtonReceiver.handleIntent(mediaSession, intent)
} }
/**
* Release this instance, closing the [MediaSessionCompat] and preventing any
* further updates to the [NotificationComponent].
*/
fun release() { fun release() {
provider.release() provider.release()
settings.release() settings.release()
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
mediaSession.apply { mediaSession.apply {
isActive = false isActive = false
release() release()
@ -112,91 +109,11 @@ class MediaSessionComponent(private val context: Context, private val callback:
invalidateSessionState() 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) { override fun onStateChanged(state: InternalPlayer.State) {
invalidateSessionState() invalidateSessionState()
notification.updatePlaying(playbackManager.playerState.isPlaying) notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!provider.isBusy) { 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() { override fun onPlay() {
playbackManager.changePlaying(true) playbackManager.setPlaying(true)
} }
override fun onPause() { override fun onPause() {
playbackManager.changePlaying(false) playbackManager.setPlaying(false)
} }
override fun onSkipToNext() { override fun onSkipToNext() {
@ -285,7 +202,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
override fun onRewind() { override fun onRewind() {
playbackManager.rewind() playbackManager.rewind()
playbackManager.changePlaying(true) playbackManager.setPlaying(true)
} }
override fun onSetRepeatMode(repeatMode: Int) { override fun onSetRepeatMode(repeatMode: Int) {
@ -324,24 +241,117 @@ class MediaSessionComponent(private val context: Context, private val callback:
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT)) 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() { private fun invalidateSessionState() {
logD("Updating media session playback state") 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 = val state =
PlaybackStateCompat.Builder() // InternalPlayer.State handles position/state information.
playbackManager.playerState.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS) .setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.index.toLong()) .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. // Add the secondary action (either repeat/shuffle depending on the configuration)
val secondaryAction =
val extraAction =
when (settings.playbackNotificationAction) { when (settings.playbackNotificationAction) {
ActionMode.SHUFFLE -> ActionMode.SHUFFLE ->
PlaybackStateCompat.CustomAction.Builder( PlaybackStateCompat.CustomAction.Builder(
@ -358,20 +368,21 @@ class MediaSessionComponent(private val context: Context, private val callback:
context.getString(R.string.desc_change_repeat), context.getString(R.string.desc_change_repeat),
playbackManager.repeatMode.icon) playbackManager.repeatMode.icon)
} }
state.addCustomAction(secondaryAction.build())
// Add the exit action so the service can be closed
val exitAction = val exitAction =
PlaybackStateCompat.CustomAction.Builder( PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_EXIT, PlaybackService.ACTION_EXIT,
context.getString(R.string.desc_exit), context.getString(R.string.desc_exit),
R.drawable.ic_close_24) R.drawable.ic_close_24)
.build() .build()
state.addCustomAction(extraAction.build())
state.addCustomAction(exitAction) state.addCustomAction(exitAction)
mediaSession.setPlaybackState(state.build()) mediaSession.setPlaybackState(state.build())
} }
/** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
private fun invalidateSecondaryAction() { private fun invalidateSecondaryAction() {
invalidateSessionState() invalidateSessionState()
@ -381,15 +392,28 @@ class MediaSessionComponent(private val context: Context, private val callback:
} }
if (!provider.isBusy) { 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 { 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" const val METADATA_KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT"
private val emptyMetadata = MediaMetadataCompat.Builder().build() private val emptyMetadata = MediaMetadataCompat.Builder().build()
private const val ACTIONS = private const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or

View file

@ -34,9 +34,9 @@ import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
/** /**
* The unified notification for [PlaybackService]. Due to the nature of how this notification is * The playback notification component. Due to race conditions regarding notification
* used, it is *not self-sufficient*. Updates have to be delivered manually, as to prevent state * updates, this component is not self-sufficient. [MediaSessionComponent] should be used
* inconsistency derived from callback order. * instead of manage it.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@ -66,7 +66,12 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
// --- STATE FUNCTIONS --- // --- STATE FUNCTIONS ---
/**
* Update the currently shown metadata in this notification.
* @param metadata The [MediaMetadataCompat] to display in this notification.
*/
fun updateMetadata(metadata: MediaMetadataCompat) { fun updateMetadata(metadata: MediaMetadataCompat) {
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)) setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
@ -78,21 +83,28 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
} else { } else {
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM)) 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) { fun updatePlaying(isPlaying: Boolean) {
mActions[2] = buildPlayPauseAction(context, isPlaying) 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) { fun updateRepeatMode(repeatMode: RepeatMode) {
mActions[0] = buildRepeatAction(context, 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) { fun updateShuffled(isShuffled: Boolean) {
mActions[0] = buildShuffleAction(context, isShuffled) mActions[0] = buildShuffleAction(context, isShuffled)
} }
@ -103,8 +115,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
context: Context, context: Context,
isPlaying: Boolean isPlaying: Boolean
): NotificationCompat.Action { ): 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) return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
} }
@ -119,9 +134,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
context: Context, context: Context,
isShuffled: Boolean isShuffled: Boolean
): NotificationCompat.Action { ): NotificationCompat.Action {
val drawableRes = val drawableRes = if (isShuffled) {
if (isShuffled) R.drawable.ic_shuffle_on_24 else R.drawable.ic_shuffle_off_24 R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
}
return buildAction(context, PlaybackService.ACTION_INVERT_SHUFFLE, drawableRes) return buildAction(context, PlaybackService.ACTION_INVERT_SHUFFLE, drawableRes)
} }
@ -129,16 +146,13 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
context: Context, context: Context,
actionName: String, actionName: String,
@DrawableRes iconRes: Int @DrawableRes iconRes: Int
): NotificationCompat.Action { ) =
val action = NotificationCompat.Action.Builder(
NotificationCompat.Action.Builder( iconRes, actionName, context.newBroadcastPendingIntent(actionName)).build()
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
return action.build()
}
companion object { companion object {
val CHANNEL_INFO = /** Notification channel used by solely the playback notification. */
private val CHANNEL_INFO =
ChannelInfo( ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.lbl_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.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -113,8 +111,10 @@ class PlaybackService :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize the player component.
replayGainProcessor = ReplayGainAudioProcessor(this) 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 // Since Auxio is a music player, only specify an audio renderer to save
// battery/apk size/cache size // battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
@ -129,62 +129,54 @@ class PlaybackService :
LibflacAudioRenderer(handler, audioListener, replayGainProcessor)) LibflacAudioRenderer(handler, audioListener, replayGainProcessor))
} }
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
player = player =
ExoPlayer.Builder(this, audioRenderer) ExoPlayer.Builder(this, audioRenderer)
.setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory)) .setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory))
// Enable automatic WakeLock support
.setWakeMode(C.WAKE_MODE_LOCAL) .setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes( .setAudioAttributes(
// Signal that we are a music player.
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(), .build(),
true) true)
.build() .build().also { it.addListener(this) }
// Initialize the core service components
player.addListener(this)
settings = Settings(this, this) settings = Settings(this, this)
foregroundManager = ForegroundManager(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) playbackManager.registerInternalPlayer(this)
musicStore.addCallback(this) musicStore.addCallback(this)
widgetComponent = WidgetComponent(this) widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, this) mediaSessionComponent = MediaSessionComponent(this, this)
registerReceiver(
IntentFilter().apply { systemReceiver,
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) IntentFilter().apply {
addAction(AudioManager.ACTION_HEADSET_PLUG) addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
addAction(ACTION_INC_REPEAT_MODE) addAction(ACTION_INC_REPEAT_MODE)
addAction(ACTION_INVERT_SHUFFLE) addAction(ACTION_INVERT_SHUFFLE)
addAction(ACTION_SKIP_PREV) addAction(ACTION_SKIP_PREV)
addAction(ACTION_PLAY_PAUSE) addAction(ACTION_PLAY_PAUSE)
addAction(ACTION_SKIP_NEXT) addAction(ACTION_SKIP_NEXT)
addAction(ACTION_EXIT) addAction(ACTION_EXIT)
addAction(WidgetProvider.ACTION_WIDGET_UPDATE) addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
}
registerReceiver(systemReceiver, this) )
}
// --- PLAYBACKSTATEMANAGER SETUP ---
logD("Service created") logD("Service created")
} }
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { 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) { if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
mediaSessionComponent.handleMediaButtonIntent(intent) mediaSessionComponent.handleMediaButtonIntent(intent)
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
// No binding, service is headless
// Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead.
override fun onBind(intent: Intent): IBinder? = null override fun onBind(intent: Intent): IBinder? = null
// TODO: Implement task removal (Have to radically alter state saving to occur at runtime) // TODO: Implement task removal (Have to radically alter state saving to occur at runtime)
@ -193,13 +185,13 @@ class PlaybackService :
super.onDestroy() super.onDestroy()
foregroundManager.release() foregroundManager.release()
settings.release()
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.changePlaying(false) playbackManager.setPlaying(false)
playbackManager.unregisterInternalPlayer(this) playbackManager.unregisterInternalPlayer(this)
musicStore.removeCallback(this) musicStore.removeCallback(this)
settings.release()
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
serviceJob.cancel() serviceJob.cancel()
@ -224,13 +216,16 @@ class PlaybackService :
override val shouldRewindWithPrev: Boolean override val shouldRewindWithPrev: Boolean
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun makeState(durationMs: Long) = override fun getState(durationMs: Long) =
InternalPlayer.State.new( 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) { override fun loadSong(song: Song?, play: Boolean) {
if (song == null) { 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") logD("Nothing playing, stopping playback")
player.stop() player.stop()
if (openAudioEffectSession) { if (openAudioEffectSession) {
@ -238,7 +233,6 @@ class PlaybackService :
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
openAudioEffectSession = false openAudioEffectSession = false
} }
stopAndSave() stopAndSave()
return return
} }
@ -263,7 +257,7 @@ class PlaybackService :
player.seekTo(positionMs) player.seekTo(positionMs)
} }
override fun changePlaying(isPlaying: Boolean) { override fun setPlaying(isPlaying: Boolean) {
player.playWhenReady = isPlaying player.playWhenReady = isPlaying
} }
@ -271,28 +265,29 @@ class PlaybackService :
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events) super.onEvents(player, events)
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) && player.playWhenReady) {
var needToSynchronize = // Mark that we have started playing so that the notification can now be posted.
events.containsAny(Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY) hasPlayed = true
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
needToSynchronize = true
if (player.playWhenReady) {
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) playbackManager.synchronizeState(this)
} }
} }
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) { if (state == Player.STATE_ENDED) {
// Player ended, repeat the current track if we are configured to.
if (playbackManager.repeatMode == RepeatMode.TRACK) { if (playbackManager.repeatMode == RepeatMode.TRACK) {
playbackManager.rewind() playbackManager.rewind()
// May be configured to pause when we repeat a track.
if (settings.pauseOnRepeat) { if (settings.pauseOnRepeat) {
playbackManager.changePlaying(false) playbackManager.setPlaying(false)
} }
} else { } else {
playbackManager.next() playbackManager.next()
@ -308,7 +303,8 @@ class PlaybackService :
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks) super.onTracksChanged(tracks)
// Try to find the currently playing track so we can update ReplayGainAudioProcessor
// with it.
for (group in tracks.groups) { for (group in tracks.groups) {
if (group.isSelected) { if (group.isSelected) {
for (i in 0 until group.length) { for (i in 0 until group.length) {
@ -327,6 +323,7 @@ class PlaybackService :
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) { if (library != null) {
// We now have a library, see if we have anything we need to do.
playbackManager.requestAction(this) playbackManager.requestAction(this)
} }
} }
@ -351,11 +348,13 @@ class PlaybackService :
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
} }
/** Stop the foreground state and hide the notification */
private fun stopAndSave() { private fun stopAndSave() {
// This session has ended, so we need to reset this flag for when the next session starts.
hasPlayed = false hasPlayed = false
if (foregroundManager.tryStopForeground()) { 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") logD("Saving playback state")
saveScope.launch { saveScope.launch {
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService)) 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 val library = musicStore.library
if (library != null) { // No library, cannot do anything.
logD("Performing action: $action") ?: return false
when (action) { logD("Performing action: $action")
is InternalPlayer.Action.RestoreState -> {
restoreScope.launch { when (action) {
playbackManager.restoreState( // Restore state -> Start a new restoreState job
PlaybackStateDatabase.getInstance(this@PlaybackService), false) is InternalPlayer.Action.RestoreState -> {
} restoreScope.launch {
} playbackManager.restoreState(
is InternalPlayer.Action.ShuffleAll -> { PlaybackStateDatabase.getInstance(this@PlaybackService), false)
playbackManager.play(null, null, settings, true) }
} }
is InternalPlayer.Action.Open -> { // Shuffle all -> Start new playback from all songs
library.findSongForUri(application, action.uri)?.let { song -> is InternalPlayer.Action.ShuffleAll -> {
playbackManager.play(song, null, settings) 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 --- // --- MEDIASESSIONCOMPONENT OVERRIDES ---
override fun onPostNotification( override fun onPostNotification(notification: NotificationComponent) {
notification: NotificationComponent?, // Do not post the notification if playback hasn't started yet. This prevents errors
reason: MediaSessionComponent.PostingReason // where changing a setting would cause the notification to appear in an unfriendly
) { // manner.
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
}
if (hasPlayed) { if (hasPlayed) {
logD("Updating notification [Reason: $reason]") logD("Updating notification")
if (!foregroundManager.tryStartForeground(notification)) { if (!foregroundManager.tryStartForeground(notification)) {
notification.post() 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 inner class PlaybackReceiver : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false private var initialHeadsetPlugEventHandled = false
@ -425,22 +422,21 @@ class PlaybackService :
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
// a non-starter since both require me to display a permission prompt // a non-starter since both require me to display a permission prompt
// 3. Some weird internal framework thing that also handles bluetooth headsets??? // 3. Some internal framework thing that also handles bluetooth headsets
// // Just use ACTION_HEADSET_PLUG.
// They should have just stopped at ACTION_HEADSET_PLUG.
AudioManager.ACTION_HEADSET_PLUG -> { AudioManager.ACTION_HEADSET_PLUG -> {
when (intent.getIntExtra("state", -1)) { when (intent.getIntExtra("state", -1)) {
0 -> pauseFromPlug() 0 -> pauseFromHeadsetPlug()
1 -> maybeResumeFromPlug() 1 -> playFromHeadsetPlug()
} }
initialHeadsetPlugEventHandled = true initialHeadsetPlugEventHandled = true
} }
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug() AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromHeadsetPlug()
// --- AUXIO EVENTS --- // --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> ACTION_PLAY_PAUSE ->
playbackManager.changePlaying(!playbackManager.playerState.isPlaying) playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
ACTION_INC_REPEAT_MODE -> ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode = playbackManager.repeatMode.increment() playbackManager.repeatMode = playbackManager.repeatMode.increment()
ACTION_INVERT_SHUFFLE -> ACTION_INVERT_SHUFFLE ->
@ -448,48 +444,40 @@ class PlaybackService :
ACTION_SKIP_PREV -> playbackManager.prev() ACTION_SKIP_PREV -> playbackManager.prev()
ACTION_SKIP_NEXT -> playbackManager.next() ACTION_SKIP_NEXT -> playbackManager.next()
ACTION_EXIT -> { ACTION_EXIT -> {
playbackManager.changePlaying(false) playbackManager.setPlaying(false)
stopAndSave() stopAndSave()
} }
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update() WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
} }
} }
/** private fun playFromHeadsetPlug() {
* Resume from a headset plug event in the case that the quirk is enabled. This // ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached,
* functionality remains a quirk for two reasons: // which would result in unexpected playback. Work around it by dropping the first
* 1. Automatically resuming more or less overrides all other audio streams, which is not // call to this function, which should come from that Intent.
* that friendly if (settings.headsetAutoplay &&
* 2. There is a bug where playback will always start when this service starts, mostly due playbackManager.song != null &&
* 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 &&
initialHeadsetPlugEventHandled) { initialHeadsetPlugEventHandled) {
logD("Device connected, resuming") logD("Device connected, resuming")
playbackManager.changePlaying(true) playbackManager.setPlaying(true)
} }
} }
/** Pause from a headset plug. */ private fun pauseFromHeadsetPlug() {
private fun pauseFromPlug() {
if (playbackManager.song != null) { if (playbackManager.song != null) {
logD("Device disconnected, pausing") logD("Device disconnected, pausing")
playbackManager.changePlaying(false) playbackManager.setPlaying(false)
} }
} }
} }
companion object { companion object {
private const val REWIND_THRESHOLD = 3000L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" 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 import org.oxycblt.auxio.util.getInteger
/** /**
* A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when it * A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when
* is activated. * [isActivated] changes.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AnimatedMaterialButton class AnimatedMaterialButton
@ -39,15 +39,17 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
override fun setActivated(activated: Boolean) { override fun setActivated(activated: Boolean) {
super.setActivated(activated) 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) { if (!isLaidOut) {
updateCornerRadiusRatio(target) // Not laid out, initialize it without animation before drawing.
updateCornerRadiusRatio(targetRadius)
return return
} }
animator?.cancel() animator?.cancel()
animator = animator =
ValueAnimator.ofFloat(currentCornerRadiusRatio, target).apply { ValueAnimator.ofFloat(currentCornerRadiusRatio, targetRadius).apply {
duration = context.getInteger(R.integer.anim_fade_enter_duration).toLong() duration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
addUpdateListener { updateCornerRadiusRatio(animatedValue as Float) } addUpdateListener { updateCornerRadiusRatio(animatedValue as Float) }
start() start()
@ -56,6 +58,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
private fun updateCornerRadiusRatio(ratio: Float) { private fun updateCornerRadiusRatio(ratio: Float) {
currentCornerRadiusRatio = ratio currentCornerRadiusRatio = ratio
// Can't reproduce the intrinsic ratio corner radius, just manually implement it with
// a dimension value.
shapeAppearanceModel = shapeAppearanceModel.withCornerSize { it.width() * ratio } shapeAppearanceModel = shapeAppearanceModel.withCornerSize { it.width() * ratio }
} }
} }

View file

@ -23,16 +23,10 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
/** /**
* A class that programmatically overrides the child layout to a left-to-right (LTR) layout * A [FrameLayout] that programmatically overrides the child layout to a left-to-right (LTR) layout
* direction. * 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
* The Material Design guidelines state that any components that represent a "Timeline" should * components.
* 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.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
open class ForcedLTRFrameLayout open class ForcedLTRFrameLayout

View file

@ -27,9 +27,8 @@ import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A wrapper around [Slider] that shows not only position and duration values, but also hacks in * A wrapper around [Slider] that shows position and duration values and sanitizes input to reduce
* bounds checking to avoid app crashes if bad position input comes in. * crashes from invalid values.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class StyledSeekBar class StyledSeekBar
@ -45,11 +44,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
binding.seekBarSlider.addOnChangeListener(this) 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 * The current position, in deci-seconds(1/10th of a second). This is the current value of the
* by the start TextView in the layout. * SeekBar and is indicated by the start TextView in the layout.
*/ */
var positionDs: Long var positionDs: Long
get() = binding.seekBarSlider.value.toLong() 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 // Sanity check 1: Ensure that no negative values are sneaking their way into
// this component. // this component.
val from = max(value, 0) val from = max(value, 0)
// Sanity check 2: Ensure that this value is within the duration and will not crash // 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 // the app, and that the user is not currently seeking (which would cause the SeekBar
// to jump around). // to jump around).
if (from <= durationDs && !isActivated) { if (from <= durationDs && !isActivated) {
binding.seekBarSlider.value = from.toFloat() binding.seekBarSlider.value = from.toFloat()
// We would want to keep this in the listener, but the listener only fires when
// We would want to keep this in the callback, but the callback only fires when
// a value changes completely, and sometimes that does not happen with this view. // a value changes completely, and sometimes that does not happen with this view.
binding.seekBarPosition.text = from.formatDurationDs(true) binding.seekBarPosition.text = from.formatDurationDs(true)
} }
} }
/** /**
* The current duration, in seconds. This is the end value of the SeekBar and is indicated by * The current duration, in deci-seconds (1/10th of a second). This is the end value of the
* the end TextView in the layout. * SeekBar and is indicated by the end TextView in the layout.
*/ */
var durationDs: Long var durationDs: Long
get() = binding.seekBarSlider.valueTo.toLong() 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. // zero, use 1 instead and disable the SeekBar.
val to = max(value, 1) val to = max(value, 1)
isEnabled = value > 0 isEnabled = value > 0
// Sanity check 2: If the current value exceeds the new duration value, clamp it // 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. // down so that we don't crash and instead have an annoying visual flicker.
if (positionDs > to) { if (positionDs > to) {
logD("Clamping invalid position [current: $positionDs new max: $to]") logD("Clamping invalid position [current: $positionDs new max: $to]")
binding.seekBarSlider.value = to.toFloat() binding.seekBarSlider.value = to.toFloat()
} }
binding.seekBarSlider.valueTo = to.toFloat() binding.seekBarSlider.valueTo = to.toFloat()
binding.seekBarDuration.text = value.formatDurationDs(false) binding.seekBarDuration.text = value.formatDurationDs(false)
} }
@ -102,12 +98,22 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {
logD("Confirming seek") 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 isActivated = false
onSeekConfirmed?.invoke(slider.value.toLong()) listener?.onSeekConfirmed(slider.value.toLong())
} }
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
binding.seekBarPosition.text = value.toLong().formatDurationDs(true) 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 * Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from
* jumping around. * jumping around.
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable. TODO: * @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
* Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
*/ */
fun expandWithRecycler(recycler: RecyclerView?) { fun expandWithRecycler(recycler: RecyclerView?) {
// TODO: Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
setExpanded(true) setExpanded(true)
recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) } recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.ui package org.oxycblt.auxio.ui
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable 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: * 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?) : abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
NeoBottomSheetBehavior<V>(context, 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 { override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
val layout = super.onLayoutChild(parent, child, layoutDirection) val layout = super.onLayoutChild(parent, child, layoutDirection)
// Don't repeat redundant initialization. // Don't repeat redundant initialization.
if (!initalized) { if (!initalized) {
child.apply { child.apply {
@ -83,14 +85,11 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
background = createBackground(context) background = createBackground(context)
setOnApplyWindowInsetsListener(::applyWindowInsets) setOnApplyWindowInsetsListener(::applyWindowInsets)
} }
initalized = true initalized = true
} }
// Sometimes CoordinatorLayout doesn't dispatch window insets to us, likely due to how // Sometimes CoordinatorLayout doesn't dispatch window insets to us, likely due to how
// much we overload it. Ensure that we get them. // much we overload it. Ensure that we get them.
child.requestApplyInsets() child.requestApplyInsets()
return layout return layout
} }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.ui package org.oxycblt.auxio.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet 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 * A behavior that automatically re-layouts and re-insets content to align with the parent layout's
* bottom sheet. * bottom sheet. Ideally, we would only want to re-inset content, but that has too many issues to
* * sensibly implement.
* 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..
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class BottomSheetContentBehavior<V : View>(context: Context, attributeSet: AttributeSet?) : class BottomSheetContentBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :

View file

@ -62,7 +62,6 @@ class NavigationViewModel : ViewModel() {
logD("Already navigating, not doing main action") logD("Already navigating, not doing main action")
return return
} }
logD("Navigating with action $action") logD("Navigating with action $action")
_mainNavigationAction.value = action _mainNavigationAction.value = action
} }
@ -78,14 +77,13 @@ class NavigationViewModel : ViewModel() {
/** /**
* Navigate to a given [Music] item. Will do nothing if already navigating. * 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) { fun exploreNavigateTo(item: Music) {
if (_exploreNavigationItem.value != null) { if (_exploreNavigationItem.value != null) {
logD("Already navigating, not doing explore action") logD("Already navigating, not doing explore action")
return return
} }
logD("Navigating to ${item.rawName}") logD("Navigating to ${item.rawName}")
_exploreNavigationItem.value = item _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: * Delegate to automatically create and destroy an object derived from the [ViewBinding].
* Phase this out, it's really dumb
* @param create Block to create the object from the [ViewBinding]. * @param create Block to create the object from the [ViewBinding].
*/ */
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> { 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 package org.oxycblt.auxio.util
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
@ -29,6 +30,7 @@ import androidx.activity.viewModels
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.database.sqlite.transaction
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -227,16 +229,6 @@ inline fun <reified T : AndroidViewModel> Fragment.androidActivityViewModels() =
inline val AndroidViewModel.context: Context inline val AndroidViewModel.context: Context
get() = getApplication() 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 * 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. * 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:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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" app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />

View file

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