playback: redocument
Redocument the playback module. This finally completes the re-documentation of this project.
This commit is contained in:
parent
8fa1c92047
commit
0737dbace3
42 changed files with 1334 additions and 1008 deletions
|
@ -87,7 +87,7 @@ import java.util.Map;
|
|||
*/
|
||||
public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
|
||||
|
||||
/** Callback for monitoring events about bottom sheets. */
|
||||
/** Listener for monitoring events about bottom sheets. */
|
||||
public abstract static class BottomSheetCallback {
|
||||
|
||||
/**
|
||||
|
@ -1205,9 +1205,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets a callback to be notified of bottom sheet events.
|
||||
* Sets a listener to be notified of bottom sheet events.
|
||||
*
|
||||
* @param callback The callback to notify when bottom sheet events occur.
|
||||
* @param callback The listener to notify when bottom sheet events occur.
|
||||
* @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
|
||||
* #removeBottomSheetCallback(BottomSheetCallback)} instead
|
||||
*/
|
||||
|
@ -1227,9 +1227,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a callback to be notified of bottom sheet events.
|
||||
* Adds a listener to be notified of bottom sheet events.
|
||||
*
|
||||
* @param callback The callback to notify when bottom sheet events occur.
|
||||
* @param callback The listener to notify when bottom sheet events occur.
|
||||
*/
|
||||
public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
|
||||
if (!callbacks.contains(callback)) {
|
||||
|
@ -1238,9 +1238,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
}
|
||||
|
||||
/**
|
||||
* Removes a previously added callback.
|
||||
* Removes a previously added listener.
|
||||
*
|
||||
* @param callback The callback to remove.
|
||||
* @param callback The listener to remove.
|
||||
*/
|
||||
public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
|
||||
callbacks.remove(callback);
|
||||
|
|
|
@ -73,7 +73,7 @@ class MainFragment :
|
|||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
// Override the back pressed callback so we can map back navigation to collapsing
|
||||
// Override the back pressed listener so we can map back navigation to collapsing
|
||||
// navigation, navigation out of detail views, etc.
|
||||
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
|
||||
|
||||
|
@ -128,7 +128,7 @@ class MainFragment :
|
|||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Callback could still reasonably fire even if we clear the binding, attach/detach
|
||||
// Listener could still reasonably fire even if we clear the binding, attach/detach
|
||||
// our pre-draw listener our listener in onStart/onStop respectively.
|
||||
requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this)
|
||||
}
|
||||
|
@ -140,7 +140,7 @@ class MainFragment :
|
|||
|
||||
override fun onPreDraw(): Boolean {
|
||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||
// callback functionality. Just update all transitions before every draw. Should
|
||||
// listener functionality. Just update all transitions before every draw. Should
|
||||
// probably be cheap enough.
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
|
@ -221,7 +221,7 @@ class MainFragment :
|
|||
tryHideAllSheets()
|
||||
}
|
||||
|
||||
// Since the callback is also reliant on the bottom sheets, we must also update it
|
||||
// Since the listener is also reliant on the bottom sheets, we must also update it
|
||||
// every frame.
|
||||
callback.invalidateEnabled()
|
||||
|
||||
|
@ -383,7 +383,7 @@ class MainFragment :
|
|||
* that the back button should close first, the instance is disabled and back navigation is
|
||||
* delegated to the system.
|
||||
*
|
||||
* Normally, this callback would have just called the [MainActivity.onBackPressed] if there
|
||||
* Normally, this listener would have just called the [MainActivity.onBackPressed] if there
|
||||
* were no components to close, but that prevents adaptive back navigation from working on
|
||||
* Android 14+, so we must do it this way.
|
||||
*/
|
||||
|
|
|
@ -187,7 +187,7 @@ class DetailViewModel(application: Application) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
logD("Opening Song [uid: $uid]")
|
||||
loadDetailSong(requireMusic(uid))
|
||||
}
|
||||
|
||||
|
@ -201,7 +201,7 @@ class DetailViewModel(application: Application) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
logD("Opening Album [uid: $uid]")
|
||||
_currentAlbum.value = requireMusic<Album>(uid).also { refreshAlbumList(it) }
|
||||
}
|
||||
|
||||
|
@ -215,7 +215,7 @@ class DetailViewModel(application: Application) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
logD("Opening Artist [uid: $uid]")
|
||||
_currentArtist.value = requireMusic<Artist>(uid).also { refreshArtistList(it) }
|
||||
}
|
||||
|
||||
|
@ -229,7 +229,7 @@ class DetailViewModel(application: Application) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
logD("Opening Genre [uid: $uid]")
|
||||
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) }
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ abstract class DetailAdapter(
|
|||
private val listener: Listener,
|
||||
itemCallback: DiffUtil.ItemCallback<Item>
|
||||
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
||||
// Safe to leak this since the callback will not fire during initialization
|
||||
// Safe to leak this since the listener will not fire during initialization
|
||||
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
|
|
|
@ -127,7 +127,7 @@ class HomeFragment :
|
|||
|
||||
// ViewPager2 will nominally consume window insets, which will then break the window
|
||||
// insets applied to the indexing view before API 30. Fix this by overriding the
|
||||
// callback with a non-consuming listener.
|
||||
// listener with a non-consuming listener.
|
||||
setOnApplyWindowInsetsListener { _, insets -> insets }
|
||||
|
||||
// We know that there will only be a fixed amount of tabs, so we manually set this
|
||||
|
|
|
@ -89,7 +89,7 @@ class BitmapProvider(private val context: Context) {
|
|||
.size(Size.ORIGINAL)
|
||||
.transformations(SquareFrameTransform.INSTANCE))
|
||||
// Override the target in order to deliver the bitmap to the given
|
||||
// callback.
|
||||
// listener.
|
||||
.target(
|
||||
onSuccess = {
|
||||
synchronized(this) {
|
||||
|
|
|
@ -22,11 +22,12 @@ import android.widget.Button
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* A basic listener for list interactions. TODO: Supply a ViewHolder on clicks (allows editable
|
||||
* lists to be standardized into a listener.)
|
||||
* A basic listener for list interactions.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface ClickableListListener {
|
||||
// TODO: Supply a ViewHolder on clicks
|
||||
// (allows editable lists to be standardized into a listener.)
|
||||
/**
|
||||
* Called when an [Item] in the list is clicked.
|
||||
* @param item The [Item] that was clicked.
|
||||
|
@ -60,15 +61,15 @@ interface SelectableListListener : ClickableListListener {
|
|||
*/
|
||||
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
|
||||
viewHolder.itemView.apply {
|
||||
// Map clicks to the click callback.
|
||||
// Map clicks to the click listener.
|
||||
setOnClickListener { onClick(item) }
|
||||
// Map long clicks to the selection callback.
|
||||
// Map long clicks to the selection listener.
|
||||
setOnLongClickListener {
|
||||
onSelect(item)
|
||||
true
|
||||
}
|
||||
}
|
||||
// Map the menu button to the menu opening callback.
|
||||
// Map the menu button to the menu opening listener.
|
||||
menuButton.setOnClickListener { onOpenMenu(item, it) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -166,7 +166,7 @@ class MusicStore private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/** A callback for changes in the music library. */
|
||||
/** A listener for changes in the music library. */
|
||||
interface Callback {
|
||||
/**
|
||||
* Called when the current [Library] has changed.
|
||||
|
|
|
@ -26,10 +26,7 @@ import androidx.core.database.getStringOrNull
|
|||
import androidx.core.database.sqlite.transaction
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.queryAll
|
||||
import org.oxycblt.auxio.util.requireBackgroundThread
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* Defines an Extractor that can load cached music. This is the first step in the music extraction
|
||||
|
@ -118,10 +115,7 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
|
|||
}
|
||||
|
||||
override fun populate(rawSong: Song.Raw): ExtractionResult {
|
||||
val map =
|
||||
requireNotNull(cacheMap) {
|
||||
"Must initialize this extractor before populating a raw song."
|
||||
}
|
||||
val map = cacheMap ?: return ExtractionResult.NONE
|
||||
|
||||
// For a cached raw song to be used, it must exist within the cache and have matching
|
||||
// addition and modification timestamps. Technically the addition timestamp doesn't
|
||||
|
@ -181,34 +175,31 @@ private class CacheDatabase(context: Context) :
|
|||
// Map the cacheable raw song fields to database fields. Cache-able in this context
|
||||
// means information independent of the file-system, excluding IDs and timestamps required
|
||||
// to retrieve items from the cache.
|
||||
val command =
|
||||
StringBuilder()
|
||||
.append("CREATE TABLE IF NOT EXISTS $TABLE_RAW_SONGS(")
|
||||
.append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
|
||||
.append("${Columns.DATE_ADDED} LONG NOT NULL,")
|
||||
.append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
|
||||
.append("${Columns.SIZE} LONG NOT NULL,")
|
||||
.append("${Columns.DURATION} LONG NOT NULL,")
|
||||
.append("${Columns.FORMAT_MIME_TYPE} STRING,")
|
||||
.append("${Columns.MUSIC_BRAINZ_ID} STRING,")
|
||||
.append("${Columns.NAME} STRING NOT NULL,")
|
||||
.append("${Columns.SORT_NAME} STRING,")
|
||||
.append("${Columns.TRACK} INT,")
|
||||
.append("${Columns.DISC} INT,")
|
||||
.append("${Columns.DATE} STRING,")
|
||||
.append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
|
||||
.append("${Columns.ALBUM_NAME} STRING NOT NULL,")
|
||||
.append("${Columns.ALBUM_SORT_NAME} STRING,")
|
||||
.append("${Columns.ALBUM_TYPES} STRING,")
|
||||
.append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
|
||||
.append("${Columns.ARTIST_NAMES} STRING,")
|
||||
.append("${Columns.ARTIST_SORT_NAMES} STRING,")
|
||||
.append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
|
||||
.append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
|
||||
.append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
|
||||
.append("${Columns.GENRE_NAMES} STRING)")
|
||||
|
||||
db.execSQL(command.toString())
|
||||
db.createTable(TABLE_RAW_SONGS) {
|
||||
append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
|
||||
append("${Columns.DATE_ADDED} LONG NOT NULL,")
|
||||
append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
|
||||
append("${Columns.SIZE} LONG NOT NULL,")
|
||||
append("${Columns.DURATION} LONG NOT NULL,")
|
||||
append("${Columns.FORMAT_MIME_TYPE} STRING,")
|
||||
append("${Columns.MUSIC_BRAINZ_ID} STRING,")
|
||||
append("${Columns.NAME} STRING NOT NULL,")
|
||||
append("${Columns.SORT_NAME} STRING,")
|
||||
append("${Columns.TRACK} INT,")
|
||||
append("${Columns.DISC} INT,")
|
||||
append("${Columns.DATE} STRING,")
|
||||
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
|
||||
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
|
||||
append("${Columns.ALBUM_SORT_NAME} STRING,")
|
||||
append("${Columns.ALBUM_TYPES} STRING,")
|
||||
append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
|
||||
append("${Columns.ARTIST_NAMES} STRING,")
|
||||
append("${Columns.ARTIST_SORT_NAMES} STRING,")
|
||||
append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
|
||||
append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
|
||||
append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
|
||||
append("${Columns.GENRE_NAMES} STRING")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
|
||||
|
@ -342,73 +333,50 @@ private class CacheDatabase(context: Context) :
|
|||
*/
|
||||
fun write(rawSongs: List<Song.Raw>) {
|
||||
val start = System.currentTimeMillis()
|
||||
var position = 0
|
||||
val database = writableDatabase
|
||||
database.transaction { delete(TABLE_RAW_SONGS, null, null) }
|
||||
|
||||
logD("Cleared raw songs database")
|
||||
writableDatabase.writeList(rawSongs, TABLE_RAW_SONGS) { _, rawSong ->
|
||||
ContentValues(22).apply {
|
||||
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
|
||||
put(Columns.DATE_ADDED, rawSong.dateAdded)
|
||||
put(Columns.DATE_MODIFIED, rawSong.dateModified)
|
||||
|
||||
while (position < rawSongs.size) {
|
||||
var i = position
|
||||
put(Columns.SIZE, rawSong.size)
|
||||
put(Columns.DURATION, rawSong.durationMs)
|
||||
put(Columns.FORMAT_MIME_TYPE, rawSong.formatMimeType)
|
||||
|
||||
database.transaction {
|
||||
while (i < rawSongs.size) {
|
||||
val rawSong = rawSongs[i]
|
||||
i++
|
||||
put(Columns.MUSIC_BRAINZ_ID, rawSong.name)
|
||||
put(Columns.NAME, rawSong.name)
|
||||
put(Columns.SORT_NAME, rawSong.sortName)
|
||||
|
||||
val itemData =
|
||||
ContentValues(22).apply {
|
||||
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
|
||||
put(Columns.DATE_ADDED, rawSong.dateAdded)
|
||||
put(Columns.DATE_MODIFIED, rawSong.dateModified)
|
||||
put(Columns.TRACK, rawSong.track)
|
||||
put(Columns.DISC, rawSong.disc)
|
||||
put(Columns.DATE, rawSong.date?.toString())
|
||||
|
||||
put(Columns.SIZE, rawSong.size)
|
||||
put(Columns.DURATION, rawSong.durationMs)
|
||||
put(Columns.FORMAT_MIME_TYPE, rawSong.formatMimeType)
|
||||
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
|
||||
put(Columns.ALBUM_NAME, rawSong.albumName)
|
||||
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
|
||||
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
|
||||
|
||||
put(Columns.MUSIC_BRAINZ_ID, rawSong.name)
|
||||
put(Columns.NAME, rawSong.name)
|
||||
put(Columns.SORT_NAME, rawSong.sortName)
|
||||
put(
|
||||
Columns.ARTIST_MUSIC_BRAINZ_IDS,
|
||||
rawSong.artistMusicBrainzIds.toSQLMultiValue())
|
||||
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
|
||||
put(
|
||||
Columns.ARTIST_SORT_NAMES,
|
||||
rawSong.artistSortNames.toSQLMultiValue())
|
||||
|
||||
put(Columns.TRACK, rawSong.track)
|
||||
put(Columns.DISC, rawSong.disc)
|
||||
put(Columns.DATE, rawSong.date?.toString())
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
|
||||
rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_NAMES,
|
||||
rawSong.albumArtistNames.toSQLMultiValue())
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_SORT_NAMES,
|
||||
rawSong.albumArtistSortNames.toSQLMultiValue())
|
||||
|
||||
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
|
||||
put(Columns.ALBUM_NAME, rawSong.albumName)
|
||||
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
|
||||
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
|
||||
|
||||
put(
|
||||
Columns.ARTIST_MUSIC_BRAINZ_IDS,
|
||||
rawSong.artistMusicBrainzIds.toSQLMultiValue())
|
||||
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
|
||||
put(
|
||||
Columns.ARTIST_SORT_NAMES,
|
||||
rawSong.artistSortNames.toSQLMultiValue())
|
||||
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
|
||||
rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_NAMES,
|
||||
rawSong.albumArtistNames.toSQLMultiValue())
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_SORT_NAMES,
|
||||
rawSong.albumArtistSortNames.toSQLMultiValue())
|
||||
|
||||
put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
|
||||
}
|
||||
|
||||
insert(TABLE_RAW_SONGS, null, itemData)
|
||||
}
|
||||
put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
|
||||
}
|
||||
|
||||
// Update the position at the end, if an insert failed at any point, then
|
||||
// the next iteration should skip it.
|
||||
position = i
|
||||
|
||||
logD("Wrote batch of raw songs. Position is now at $position")
|
||||
}
|
||||
|
||||
logD("Wrote cache in ${System.currentTimeMillis() - start}ms")
|
||||
|
|
|
@ -91,6 +91,7 @@ abstract class MediaStoreExtractor(
|
|||
|
||||
// Filter out music that is not music, if enabled.
|
||||
if (settings.excludeNonMusic) {
|
||||
logD("Excluding non-music")
|
||||
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
|
||||
}
|
||||
|
||||
|
|
|
@ -65,7 +65,7 @@ class MetadataExtractor(
|
|||
/**
|
||||
* Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the
|
||||
* sub-extractors before parsing the metadata itself.
|
||||
* @param emit A callback that will be invoked with every new [Song.Raw] instance when they are
|
||||
* @param emit A listener that will be invoked with every new [Song.Raw] instance when they are
|
||||
* successfully loaded.
|
||||
*/
|
||||
suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
|
||||
|
@ -131,7 +131,7 @@ class MetadataExtractor(
|
|||
class Task(context: Context, private val raw: Song.Raw) {
|
||||
// Note that we do not leverage future callbacks. This is because errors in the
|
||||
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||
// callback is used, instead crashing the app entirely.
|
||||
// listener is used, instead crashing the app entirely.
|
||||
private val future =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
context,
|
||||
|
|
|
@ -113,11 +113,11 @@ class Indexer private constructor() {
|
|||
@Synchronized
|
||||
fun registerCallback(callback: Callback) {
|
||||
if (BuildConfig.DEBUG && this.callback != null) {
|
||||
logW("Callback is already registered")
|
||||
logW("Listener is already registered")
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the callback with the current state.
|
||||
// Initialize the listener with the current state.
|
||||
val currentState =
|
||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||
callback.onIndexerStateChanged(currentState)
|
||||
|
@ -473,7 +473,7 @@ class Indexer private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* A callback for rapid-fire changes in the music loading state.
|
||||
* A listener for rapid-fire changes in the music loading state.
|
||||
*
|
||||
* This is only useful for code that absolutely must show the current loading process.
|
||||
* Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of
|
||||
|
|
|
@ -78,7 +78,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
|
||||
// Initialize any callback-dependent components last as we wouldn't want a callback race
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
indexerContentObserver = SystemContentObserver()
|
||||
settings = Settings(this, this)
|
||||
|
@ -102,7 +102,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// De-initialize core service components first.
|
||||
foregroundManager.release()
|
||||
wakeLock.releaseSafe()
|
||||
// Then cancel the callback-dependent components to ensure that stray reloading
|
||||
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||
// events will not occur.
|
||||
indexerContentObserver.release()
|
||||
settings.release()
|
||||
|
@ -137,8 +137,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
// to a callback as it is bad practice for a shared object to attach to
|
||||
// the callback system of another.
|
||||
// to a listener as it is bad practice for a shared object to attach to
|
||||
// the listener system of another.
|
||||
playbackManager.sanitize(newLibrary)
|
||||
}
|
||||
// Forward the new library to MusicStore to continue the update process.
|
||||
|
|
|
@ -19,12 +19,23 @@ package org.oxycblt.auxio.playback
|
|||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
|
||||
/** Represents custom actions available in certain areas of the playback UI. */
|
||||
/**
|
||||
* Represents a configuration option for what kind of "secondary" action to show in a particular
|
||||
* context.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class ActionMode {
|
||||
/** Use a "Skip next" button for the secondary action. */
|
||||
NEXT,
|
||||
/** Use a repeat mode button for the secondary action. */
|
||||
REPEAT,
|
||||
/** Use a shuffle mode button for the secondary action. */
|
||||
SHUFFLE;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
* @see fromIntCode
|
||||
*/
|
||||
val intCode: Int
|
||||
get() =
|
||||
when (this) {
|
||||
|
@ -34,9 +45,14 @@ enum class ActionMode {
|
|||
}
|
||||
|
||||
companion object {
|
||||
/** Convert an int [code] into an instance, or null if it isn't valid. */
|
||||
fun fromIntCode(code: Int) =
|
||||
when (code) {
|
||||
/**
|
||||
* Convert a [ActionMode] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [ActionMode]
|
||||
* @return The corresponding [ActionMode], or null if the [ActionMode] is invalid.
|
||||
* @see ActionMode.intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.ACTION_MODE_NEXT -> NEXT
|
||||
IntegerTable.ACTION_MODE_REPEAT -> REPEAT
|
||||
IntegerTable.ACTION_MODE_SHUFFLE -> SHUFFLE
|
||||
|
|
|
@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.getAttrColorCompat
|
|||
import org.oxycblt.auxio.util.getColorCompat
|
||||
|
||||
/**
|
||||
* A fragment showing the current playback state in a compact manner. Used as the bar for the
|
||||
* playback sheet.
|
||||
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||
|
@ -49,30 +48,32 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
binding: FragmentPlaybackBarBinding,
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
val context = requireContext()
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.root.apply {
|
||||
setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) }
|
||||
|
||||
setOnLongClickListener {
|
||||
playbackModel.song.value?.let(navModel::exploreNavigateTo)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// Set up marquee on song information
|
||||
binding.playbackSong.isSelected = true
|
||||
binding.playbackInfo.isSelected = true
|
||||
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
|
||||
// Set up actions
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
||||
setupSecondaryActions(binding, Settings(context))
|
||||
|
||||
// Load the track color in manually as it's unclear whether the track actually supports
|
||||
// using a ColorStateList in the resources
|
||||
// using a ColorStateList in the resources.
|
||||
binding.playbackProgressBar.trackColor =
|
||||
context.getColorCompat(R.color.sel_track).defaultColor
|
||||
|
||||
// -- VIEWMODEL SETUP ---
|
||||
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
|
||||
collectImmediately(playbackModel.positionDs, ::updatePosition)
|
||||
|
@ -80,6 +81,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
// Marquee elements leak if they are not disabled when the views are destroyed.
|
||||
binding.playbackSong.isSelected = false
|
||||
binding.playbackInfo.isSelected = false
|
||||
}
|
||||
|
@ -98,7 +100,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
binding.playbackSecondaryAction.apply {
|
||||
contentDescription = getString(R.string.desc_change_repeat)
|
||||
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
||||
setOnClickListener { playbackModel.incrementRepeatMode() }
|
||||
setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
|
||||
}
|
||||
}
|
||||
|
@ -132,6 +134,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
private fun updateRepeat(repeatMode: RepeatMode) {
|
||||
requireBinding().playbackSecondaryAction.apply {
|
||||
setIconResource(repeatMode.icon)
|
||||
// Icon tinting is controlled through isActivated, so update that flag as well.
|
||||
isActivated = repeatMode != RepeatMode.NONE
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,13 +25,12 @@ import android.view.View
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior
|
||||
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
|
||||
/**
|
||||
* The coordinator layout behavior used for the playback sheet, hacking in the many fixes required
|
||||
* to make bottom sheets like this work.
|
||||
* The [BaseBottomSheetBehavior] for the playback bottom sheet. This bottom sheet
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
|
@ -57,6 +56,8 @@ class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: Attr
|
|||
override fun createBackground(context: Context) =
|
||||
LayerDrawable(
|
||||
arrayOf(
|
||||
// Add another colored background so that there is always an obscuring
|
||||
// element even as the actual "background" element is faded out.
|
||||
MaterialShapeDrawable(sheetBackgroundDrawable.shapeAppearanceModel).apply {
|
||||
fillColor = sheetBackgroundDrawable.fillColor
|
||||
},
|
||||
|
|
|
@ -24,8 +24,8 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.MainFragmentDirections
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
|
|||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
|
@ -42,18 +43,16 @@ import org.oxycblt.auxio.util.showToast
|
|||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* A [Fragment] that displays more information about the song, along with more media controls.
|
||||
* A [ViewBindingFragment] more information about the currently playing song, alongside all
|
||||
* available controls.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Make seek thumb grow when selected
|
||||
*/
|
||||
class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>() {
|
||||
class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(), Toolbar.OnMenuItemClickListener, StyledSeekBar.Listener {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
|
||||
// AudioEffect expects you to use startActivityForResult with the panel intent. Use
|
||||
// the contract analogue for this since there is no built-in contract for AudioEffect.
|
||||
private val activityLauncher by lifecycleObject {
|
||||
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
|
||||
// contract analogue for this intent, so the generic contract is used instead.
|
||||
private val equalizerLauncher by lifecycleObject {
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
// Nothing to do
|
||||
}
|
||||
|
@ -66,8 +65,9 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
|
|||
binding: FragmentPlaybackPanelBinding,
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
// --- UI SETUP ---
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.root.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
view.updatePadding(top = bars.top, bottom = bars.bottom)
|
||||
|
@ -76,39 +76,34 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
|
|||
|
||||
binding.playbackToolbar.apply {
|
||||
setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) }
|
||||
setOnMenuItemClickListener {
|
||||
handleMenuItem(it)
|
||||
true
|
||||
}
|
||||
setOnMenuItemClickListener(this@PlaybackPanelFragment)
|
||||
}
|
||||
|
||||
// Make sure we enable marquee on the song info
|
||||
|
||||
// Set up marquee on song information, alongside click handlers that navigate to each
|
||||
// respective item.
|
||||
binding.playbackSong.apply {
|
||||
isSelected = true
|
||||
setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) }
|
||||
}
|
||||
|
||||
binding.playbackArtist.apply {
|
||||
isSelected = true
|
||||
setOnClickListener { playbackModel.song.value?.let { showCurrentArtist() } }
|
||||
setOnClickListener { navigateToCurrentArtist() }
|
||||
}
|
||||
|
||||
binding.playbackAlbum.apply {
|
||||
isSelected = true
|
||||
setOnClickListener { playbackModel.song.value?.let { showCurrentAlbum() } }
|
||||
setOnClickListener { navigateToCurrentAlbum() }
|
||||
}
|
||||
|
||||
binding.playbackSeekBar.onSeekConfirmed = playbackModel::seekTo
|
||||
binding.playbackSeekBar.listener = this
|
||||
|
||||
binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() }
|
||||
// Set up actions
|
||||
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
||||
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
|
||||
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
|
||||
|
||||
// --- VIEWMODEL SETUP --
|
||||
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.parent, ::updateParent)
|
||||
collectImmediately(playbackModel.positionDs, ::updatePosition)
|
||||
|
@ -118,32 +113,40 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
|
|||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
|
||||
// Leaving marquee on will cause a leak
|
||||
binding.playbackToolbar.setOnMenuItemClickListener(null)
|
||||
// Marquee elements leak if they are not disabled when the views are destroyed.
|
||||
binding.playbackSong.isSelected = false
|
||||
binding.playbackArtist.isSelected = false
|
||||
binding.playbackAlbum.isSelected = false
|
||||
}
|
||||
|
||||
private fun handleMenuItem(item: MenuItem) {
|
||||
override fun onMenuItemClick(item: MenuItem) =
|
||||
when (item.itemId) {
|
||||
R.id.action_open_equalizer -> {
|
||||
// Launch the system equalizer app, if possible.
|
||||
val equalizerIntent =
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
||||
// Provide audio session ID so equalizer can show options for this app
|
||||
// in particular.
|
||||
.putExtra(
|
||||
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
|
||||
// Signal music type so that the equalizer settings are appropriate for
|
||||
// music playback.
|
||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
|
||||
try {
|
||||
activityLauncher.launch(equalizerIntent)
|
||||
equalizerLauncher.launch(equalizerIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
requireContext().showToast(R.string.err_no_app)
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
showCurrentArtist()
|
||||
navigateToCurrentArtist()
|
||||
true
|
||||
}
|
||||
R.id.action_go_album -> {
|
||||
showCurrentAlbum()
|
||||
navigateToCurrentAlbum()
|
||||
true
|
||||
}
|
||||
R.id.action_song_detail -> {
|
||||
playbackModel.song.value?.let { song ->
|
||||
|
@ -151,8 +154,13 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
|
|||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionShowDetails(song.uid)))
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onSeekConfirmed(positionDs: Long) {
|
||||
playbackModel.seekTo(positionDs)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
|
@ -192,12 +200,14 @@ class PlaybackPanelFragment : ViewBindingFragment<FragmentPlaybackPanelBinding>(
|
|||
requireBinding().playbackShuffle.isActivated = isShuffled
|
||||
}
|
||||
|
||||
private fun showCurrentArtist() {
|
||||
/** Navigate to one of the currently playing [Song]'s Artists. */
|
||||
private fun navigateToCurrentArtist() {
|
||||
val song = playbackModel.song.value ?: return
|
||||
navModel.exploreNavigateTo(song.artists)
|
||||
}
|
||||
|
||||
private fun showCurrentAlbum() {
|
||||
/** Navigate to the currently playing [Song]'s albums. */
|
||||
private fun navigateToCurrentAlbum() {
|
||||
val song = playbackModel.song.value ?: return
|
||||
navModel.exploreNavigateTo(song.album)
|
||||
}
|
||||
|
|
|
@ -20,52 +20,65 @@ package org.oxycblt.auxio.playback
|
|||
import android.text.format.DateUtils
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/** Converts a long in milliseconds to a long in deci-seconds */
|
||||
/**
|
||||
* Convert milliseconds into deci-seconds (1/10th of a second).
|
||||
* @return A converted deci-second value.
|
||||
*/
|
||||
fun Long.msToDs() = floorDiv(100)
|
||||
|
||||
/** Converts a long in milliseconds to a long in seconds */
|
||||
/**
|
||||
* Convert milliseconds into seconds.
|
||||
* @return A converted second value.
|
||||
*/
|
||||
fun Long.msToSecs() = floorDiv(1000)
|
||||
|
||||
/** Converts a long in deci-seconds to a long in milliseconds. */
|
||||
/**
|
||||
* Convert deci-seconds (1/10th of a second) into milliseconds.
|
||||
* @return A converted millisecond value.
|
||||
*/
|
||||
fun Long.dsToMs() = times(100)
|
||||
|
||||
/** Converts a long in deci-seconds to a long in seconds. */
|
||||
/**
|
||||
* Convert deci-seconds (1/10th of a second) into seconds.
|
||||
* @return A converted second value.
|
||||
*/
|
||||
fun Long.dsToSecs() = floorDiv(10)
|
||||
|
||||
/** Converts a long in seconds to a long in milliseconds. */
|
||||
/**
|
||||
* Convert seconds into milliseconds.
|
||||
* @return A converted millisecond value.
|
||||
*/
|
||||
fun Long.secsToMs() = times(1000)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of milliseconds into a string duration.
|
||||
* Convert a millisecond value into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of deci-seconds into a string duration.
|
||||
// * Format a deci-second value (1/10th of a second) into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationDs(isElapsed: Boolean) = dsToSecs().formatDurationSecs(isElapsed)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of seconds into a string duration.
|
||||
* Convert a second value into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationSecs(isElapsed: Boolean): String {
|
||||
if (!isElapsed && this == 0L) {
|
||||
logD("Non-elapsed duration is zero, using --:--")
|
||||
// Non-elapsed duration is zero, return default value.
|
||||
return "--:--"
|
||||
}
|
||||
|
||||
var durationString = DateUtils.formatElapsedTime(this)
|
||||
|
||||
// If the duration begins with a excess zero [e.g 01:42], then cut it off.
|
||||
// Remove trailing zero values [i.e 01:42]. This is primarily for aesthetics.
|
||||
if (durationString[0] == '0') {
|
||||
durationString = durationString.slice(1 until durationString.length)
|
||||
}
|
||||
|
||||
return durationString
|
||||
}
|
||||
|
|
|
@ -34,284 +34,60 @@ import org.oxycblt.auxio.settings.Settings
|
|||
import org.oxycblt.auxio.util.context
|
||||
|
||||
/**
|
||||
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
|
||||
*
|
||||
* **PLEASE Use this instead of [PlaybackStateManager], UIs are extremely volatile and this provides
|
||||
* an interface that properly sanitizes input and abstracts functions unlike the master class.**
|
||||
*
|
||||
* The ViewModel that provides a safe UI frontend for [PlaybackStateManager].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Queue additions without a song should map to playing selected
|
||||
*/
|
||||
class PlaybackViewModel(application: Application) :
|
||||
AndroidViewModel(application), PlaybackStateManager.Callback {
|
||||
private val settings = Settings(application)
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private var lastPositionJob: Job? = null
|
||||
|
||||
private val _song = MutableStateFlow<Song?>(null)
|
||||
|
||||
/** The current song. */
|
||||
/** The currently playing song. */
|
||||
val song: StateFlow<Song?>
|
||||
get() = _song
|
||||
private val _parent = MutableStateFlow<MusicParent?>(null)
|
||||
|
||||
/** The current model that is being played from, such as an [Album] or [Artist] */
|
||||
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
||||
val parent: StateFlow<MusicParent?> = _parent
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
/** Whether playback is ongoing or paused.*/
|
||||
val isPlaying: StateFlow<Boolean>
|
||||
get() = _isPlaying
|
||||
private val _positionDs = MutableStateFlow(0L)
|
||||
|
||||
/** The current playback position, in *deci-seconds* */
|
||||
/** The current position, in deci-seconds (1/10th of a second). */
|
||||
val positionDs: StateFlow<Long>
|
||||
get() = _positionDs
|
||||
|
||||
private val _repeatMode = MutableStateFlow(RepeatMode.NONE)
|
||||
|
||||
/** The current repeat mode, see [RepeatMode] for more information */
|
||||
/** The current [RepeatMode]. */
|
||||
val repeatMode: StateFlow<RepeatMode>
|
||||
get() = _repeatMode
|
||||
private val _isShuffled = MutableStateFlow(false)
|
||||
/** Whether the queue is shuffled or not. */
|
||||
val isShuffled: StateFlow<Boolean>
|
||||
get() = _isShuffled
|
||||
|
||||
/** The current ID of the app's audio session. */
|
||||
val currentAudioSessionId: Int?
|
||||
get() = playbackManager.currentAudioSessionId
|
||||
|
||||
private var lastPositionJob: Job? = null
|
||||
|
||||
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
|
||||
|
||||
/** Flag for resolving an ambiguous artist choice when playing from a song's artists. */
|
||||
/**
|
||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous [Artist] choice when
|
||||
* playing a [Song] from one of it's [Artist]s.
|
||||
* @see playFromArtist
|
||||
*/
|
||||
val artistPlaybackPickerSong: StateFlow<Song?>
|
||||
get() = _artistPlaybackPickerSong
|
||||
|
||||
/**
|
||||
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
|
||||
* available.
|
||||
*/
|
||||
val currentAudioSessionId: Int?
|
||||
get() = playbackManager.currentAudioSessionId
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
}
|
||||
|
||||
// --- PLAYING FUNCTIONS ---
|
||||
|
||||
/** Play a [song] from all songs. */
|
||||
fun playFromAll(song: Song) {
|
||||
playbackManager.play(song, null, settings)
|
||||
}
|
||||
|
||||
/** Shuffle all songs */
|
||||
fun shuffleAll() {
|
||||
playbackManager.play(null, null, settings, true)
|
||||
}
|
||||
|
||||
/** Play a song from it's album. */
|
||||
fun playFromAlbum(song: Song) {
|
||||
playbackManager.play(song, song.album, settings)
|
||||
}
|
||||
|
||||
/** Play a song from it's artist. */
|
||||
fun playFromArtist(song: Song, artist: Artist? = null) {
|
||||
if (artist != null) {
|
||||
check(artist in song.artists) { "Artist not in song artists" }
|
||||
playbackManager.play(song, artist, settings)
|
||||
} else {
|
||||
if (song.artists.size == 1) {
|
||||
playbackManager.play(song, song.artists[0], settings)
|
||||
} else {
|
||||
_artistPlaybackPickerSong.value = song
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete the picker opening process when playing from an artist. */
|
||||
fun finishPlaybackArtistPicker() {
|
||||
_artistPlaybackPickerSong.value = null
|
||||
}
|
||||
|
||||
/** Play a song from the specific genre that contains the song. */
|
||||
fun playFromGenre(song: Song, genre: Genre) {
|
||||
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
|
||||
playbackManager.play(song, genre, settings)
|
||||
}
|
||||
|
||||
/** Play an [album]. */
|
||||
fun play(album: Album) {
|
||||
playbackManager.play(null, album, settings, false)
|
||||
}
|
||||
|
||||
/** Play an [artist]. */
|
||||
fun play(artist: Artist) {
|
||||
playbackManager.play(null, artist, settings, false)
|
||||
}
|
||||
|
||||
/** Play a [genre]. */
|
||||
fun play(genre: Genre) {
|
||||
playbackManager.play(null, genre, settings, false)
|
||||
}
|
||||
|
||||
/** Shuffle an [album]. */
|
||||
fun shuffle(album: Album) {
|
||||
playbackManager.play(null, album, settings, true)
|
||||
}
|
||||
|
||||
/** Shuffle an [artist]. */
|
||||
fun shuffle(artist: Artist) {
|
||||
playbackManager.play(null, artist, settings, true)
|
||||
}
|
||||
|
||||
/** Shuffle a [genre]. */
|
||||
fun shuffle(genre: Genre) {
|
||||
playbackManager.play(null, genre, settings, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the given [InternalPlayer.Action].
|
||||
*
|
||||
* These are a class of playback actions that must have music present to function, usually
|
||||
* alongside a context too. Examples include:
|
||||
* - Opening files
|
||||
* - Restoring the playback state
|
||||
* - App shortcuts
|
||||
*/
|
||||
fun startAction(action: InternalPlayer.Action) {
|
||||
playbackManager.startAction(action)
|
||||
}
|
||||
|
||||
// --- PLAYER FUNCTIONS ---
|
||||
|
||||
/** Update the position and push it to [PlaybackStateManager] */
|
||||
fun seekTo(positionDs: Long) {
|
||||
playbackManager.seekTo(positionDs.dsToMs())
|
||||
}
|
||||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
||||
/** Skip to the next song. */
|
||||
fun next() {
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
/** Skip to the previous song. */
|
||||
fun prev() {
|
||||
playbackManager.prev()
|
||||
}
|
||||
|
||||
/** Add a [Song] to the top of the queue. */
|
||||
fun playNext(song: Song) {
|
||||
playbackManager.playNext(song)
|
||||
}
|
||||
|
||||
/** Add an [Album] to the top of the queue. */
|
||||
fun playNext(album: Album) {
|
||||
playbackManager.playNext(settings.detailAlbumSort.songs(album.songs))
|
||||
}
|
||||
|
||||
/** Add an [Artist] to the top of the queue. */
|
||||
fun playNext(artist: Artist) {
|
||||
playbackManager.playNext(settings.detailArtistSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
/** Add a [Genre] to the top of the queue. */
|
||||
fun playNext(genre: Genre) {
|
||||
playbackManager.playNext(settings.detailGenreSort.songs(genre.songs))
|
||||
}
|
||||
|
||||
/** Add a selection [selection] to the top of the queue. */
|
||||
fun playNext(selection: List<Music>) {
|
||||
playbackManager.playNext(selectionToSongs(selection))
|
||||
}
|
||||
|
||||
/** Add a [Song] to the end of the queue. */
|
||||
fun addToQueue(song: Song) {
|
||||
playbackManager.addToQueue(song)
|
||||
}
|
||||
|
||||
/** Add an [Album] to the end of the queue. */
|
||||
fun addToQueue(album: Album) {
|
||||
playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs))
|
||||
}
|
||||
|
||||
/** Add an [Artist] to the end of the queue. */
|
||||
fun addToQueue(artist: Artist) {
|
||||
playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
/** Add a [Genre] to the end of the queue. */
|
||||
fun addToQueue(genre: Genre) {
|
||||
playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs))
|
||||
}
|
||||
|
||||
/** Add a selection [selection] to the top of the queue. */
|
||||
fun addToQueue(selection: List<Music>) {
|
||||
playbackManager.addToQueue(selectionToSongs(selection))
|
||||
}
|
||||
|
||||
private fun selectionToSongs(selection: List<Music>): List<Song> {
|
||||
return selection.flatMap {
|
||||
when (it) {
|
||||
is Album -> settings.detailAlbumSort.songs(it.songs)
|
||||
is Artist -> settings.detailArtistSort.songs(it.songs)
|
||||
is Genre -> settings.detailGenreSort.songs(it.songs)
|
||||
is Song -> listOf(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- STATUS FUNCTIONS ---
|
||||
|
||||
/** Flip the playing status, e.g from playing to paused */
|
||||
fun invertPlaying() {
|
||||
playbackManager.changePlaying(!playbackManager.playerState.isPlaying)
|
||||
}
|
||||
|
||||
/** Flip the shuffle status, e.g from on to off. Will keep song by default. */
|
||||
fun invertShuffled() {
|
||||
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
|
||||
}
|
||||
|
||||
/** Increment the repeat mode, e.g from [RepeatMode.NONE] to [RepeatMode.ALL] */
|
||||
fun incrementRepeatMode() {
|
||||
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
||||
}
|
||||
|
||||
// --- SAVE/RESTORE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Force save the current [PlaybackStateManager] state to the database. [onDone] will be called
|
||||
* with true if it was done, or false if an error occurred.
|
||||
*/
|
||||
fun savePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context))
|
||||
onDone(saved)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the saved playback state (if any). [onDone] will be called with true if it was
|
||||
* successfully done, or false if an error occurred.
|
||||
*/
|
||||
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context))
|
||||
onDone(wiped)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force restore the last [PlaybackStateManager] saved state, regardless of if a library exists
|
||||
* or not. [onDone] will be called with true if it was successfully done, or false if there was
|
||||
* no state, a library was not present, or there was an error.
|
||||
*/
|
||||
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val restored =
|
||||
playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true)
|
||||
onDone(restored)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
||||
override fun onCleared() {
|
||||
playbackManager.removeCallback(this)
|
||||
}
|
||||
|
@ -327,14 +103,16 @@ class PlaybackViewModel(application: Application) :
|
|||
|
||||
override fun onStateChanged(state: InternalPlayer.State) {
|
||||
_isPlaying.value = state.isPlaying
|
||||
_positionDs.value = state.calculateElapsedPosition().msToDs()
|
||||
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
|
||||
|
||||
// Start watching the position again
|
||||
// Cancel the previous position job relying on old state information and create
|
||||
// a new one.
|
||||
lastPositionJob?.cancel()
|
||||
lastPositionJob =
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
_positionDs.value = state.calculateElapsedPosition().msToDs()
|
||||
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
|
||||
// Wait a deci-second for the next position tick.
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
@ -347,4 +125,297 @@ class PlaybackViewModel(application: Application) :
|
|||
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||
_repeatMode.value = repeatMode
|
||||
}
|
||||
|
||||
// --- PLAYING FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Play the given [Song] from all songs in the music library.
|
||||
* @param song The [Song] to play.
|
||||
*/
|
||||
fun playFromAll(song: Song) {
|
||||
playbackManager.play(song, null, settings)
|
||||
}
|
||||
|
||||
/** Shuffle all songs in the music library. */
|
||||
fun shuffleAll() {
|
||||
playbackManager.play(null, null, settings, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a [Song] from it's [Album].
|
||||
*/
|
||||
fun playFromAlbum(song: Song) {
|
||||
playbackManager.play(song, song.album, settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a [Song] from one of it's [Artist]s.
|
||||
* @param song The [Song] to play.
|
||||
* @param artist The [Artist] to play from. Must be linked to the [Song]. If null, the user
|
||||
* will be prompted on what artist to play. Defaults to null.
|
||||
*/
|
||||
fun playFromArtist(song: Song, artist: Artist? = null) {
|
||||
if (artist != null) {
|
||||
check(artist in song.artists) { "Artist not in song artists" }
|
||||
playbackManager.play(song, artist, settings)
|
||||
} else if (song.artists.size == 1) {
|
||||
playbackManager.play(song, song.artists[0], settings)
|
||||
} else {
|
||||
_artistPlaybackPickerSong.value = song
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the [Artist] playback choice process as complete. This should occur when the [Artist]
|
||||
* choice dialog is opened after this flag is detected.
|
||||
* @see playFromArtist
|
||||
*/
|
||||
fun finishPlaybackArtistPicker() {
|
||||
_artistPlaybackPickerSong.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* PLay a [Song] from one of it's [Genre]s.
|
||||
* @param song The [Song] to play.
|
||||
* @param genre The [Genre] to play from. Must be linked to the [Song].
|
||||
*/
|
||||
fun playFromGenre(song: Song, genre: Genre) {
|
||||
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
|
||||
playbackManager.play(song, genre, settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play an [Album].
|
||||
* @param album The [Album] to play.
|
||||
*/
|
||||
fun play(album: Album) {
|
||||
playbackManager.play(null, album, settings, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play an [Artist].
|
||||
* @param artist The [Artist] to play.
|
||||
*/
|
||||
fun play(artist: Artist) {
|
||||
playbackManager.play(null, artist, settings, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a [Genre].
|
||||
* @param genre The [Genre] to play.
|
||||
*/
|
||||
fun play(genre: Genre) {
|
||||
playbackManager.play(null, genre, settings, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an [Album].
|
||||
* @param album The [Album] to shuffle.
|
||||
*/
|
||||
fun shuffle(album: Album) {
|
||||
playbackManager.play(null, album, settings, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an [Artist].
|
||||
* @param artist The [Artist] to shuffle.
|
||||
*/
|
||||
fun shuffle(artist: Artist) {
|
||||
playbackManager.play(null, artist, settings, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an [Genre].
|
||||
* @param genre The [Genre] to shuffle.
|
||||
*/
|
||||
fun shuffle(genre: Genre) {
|
||||
playbackManager.play(null, genre, settings, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the given [InternalPlayer.Action] to be completed eventually. This can be used
|
||||
* to enqueue a playback action at startup to then occur when the music library is fully loaded.
|
||||
* @param action The [InternalPlayer.Action] to perform eventually.
|
||||
*/
|
||||
fun startAction(action: InternalPlayer.Action) {
|
||||
playbackManager.startAction(action)
|
||||
}
|
||||
|
||||
// --- PLAYER FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Seek to the given position in the currently playing [Song].
|
||||
* @param positionDs The position to seek to, in deci-seconds (1/10th of a second).
|
||||
*/
|
||||
fun seekTo(positionDs: Long) {
|
||||
playbackManager.seekTo(positionDs.dsToMs())
|
||||
}
|
||||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
||||
/** Skip to the next [Song]. */
|
||||
fun next() {
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
/** Skip to the previous [Song]. */
|
||||
fun prev() {
|
||||
playbackManager.prev()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Song] to the top of the queue.
|
||||
* @param song The [Song] to add.
|
||||
*/
|
||||
fun playNext(song: Song) {
|
||||
// TODO: Queue additions without a playing song should map to queued items
|
||||
// (impossible until queue rework)
|
||||
playbackManager.playNext(song)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Album] to the top of the queue.
|
||||
* @param album The [Album] to add.
|
||||
*/
|
||||
fun playNext(album: Album) {
|
||||
playbackManager.playNext(settings.detailAlbumSort.songs(album.songs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Artist] to the top of the queue.
|
||||
* @param artist The [Artist] to add.
|
||||
*/
|
||||
fun playNext(artist: Artist) {
|
||||
playbackManager.playNext(settings.detailArtistSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Genre] to the top of the queue.
|
||||
* @param genre The [Genre] to add.
|
||||
*/
|
||||
fun playNext(genre: Genre) {
|
||||
playbackManager.playNext(settings.detailGenreSort.songs(genre.songs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a selection to the top of the queue.
|
||||
* @param selection The [Music] selection to add.
|
||||
*/
|
||||
fun playNext(selection: List<Music>) {
|
||||
playbackManager.playNext(selectionToSongs(selection))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Song] to the end of the queue.
|
||||
* @param song The [Song] to add.
|
||||
*/
|
||||
fun addToQueue(song: Song) {
|
||||
playbackManager.addToQueue(song)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Album] to the end of the queue.
|
||||
* @param album The [Album] to add.
|
||||
*/
|
||||
fun addToQueue(album: Album) {
|
||||
playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Artist] to the end of the queue.
|
||||
* @param artist The [Artist] to add.
|
||||
*/
|
||||
fun addToQueue(artist: Artist) {
|
||||
playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Genre] to the end of the queue.
|
||||
* @param genre The [Genre] to add.
|
||||
*/
|
||||
fun addToQueue(genre: Genre) {
|
||||
playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a selection to the end of the queue.
|
||||
* @param selection The [Music] selection to add.
|
||||
*/
|
||||
fun addToQueue(selection: List<Music>) {
|
||||
playbackManager.addToQueue(selectionToSongs(selection))
|
||||
}
|
||||
|
||||
// --- STATUS FUNCTIONS ---
|
||||
|
||||
/** Toggle [isPlaying] (i.e from playing to paused) */
|
||||
fun toggleIsPlaying() {
|
||||
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
||||
}
|
||||
|
||||
/** Toggle [isShuffled] (ex. from on to off) */
|
||||
fun invertShuffled() {
|
||||
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle [repeatMode] (ex. from [RepeatMode.NONE] to [RepeatMode.TRACK])
|
||||
* @see RepeatMode.increment
|
||||
*/
|
||||
fun toggleRepeatMode() {
|
||||
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
||||
}
|
||||
|
||||
// --- SAVE/RESTORE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Force-save the current playback state.
|
||||
* @param onDone Called when the save is completed with true if successful, and false otherwise.
|
||||
*/
|
||||
fun savePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context))
|
||||
onDone(saved)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current playback state.
|
||||
* @param onDone Called when the wipe is completed with true if successful, and false otherwise.
|
||||
*/
|
||||
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context))
|
||||
onDone(wiped)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-restore the current playback state.
|
||||
* @param onDone Called when the restoration is completed with true if successful, and false
|
||||
* otherwise.
|
||||
*/
|
||||
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val restored =
|
||||
playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true)
|
||||
onDone(restored)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given selection to a list of [Song]s.
|
||||
* @param selection The selection of [Music] to convert.
|
||||
* @return A [Song] list containing the child items of any [MusicParent] instances in the list
|
||||
* alongside the unchanged [Song]s or the original selection.
|
||||
*/
|
||||
private fun selectionToSongs(selection: List<Music>): List<Song> {
|
||||
return selection.flatMap {
|
||||
when (it) {
|
||||
is Album -> settings.detailAlbumSort.songs(it.songs)
|
||||
is Artist -> settings.detailArtistSort.songs(it.songs)
|
||||
is Genre -> settings.detailGenreSort.songs(it.songs)
|
||||
is Song -> listOf(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import android.view.WindowInsets
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior
|
||||
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
|
|
|
@ -90,9 +90,8 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
|
|||
// If requested, scroll to a new item (occurs when the index moves)
|
||||
val scrollTo = queueModel.scrollTo
|
||||
if (scrollTo != null) {
|
||||
// Do not scroll to indices that are not in the currently visible range.
|
||||
// This prevents the queue from jumping around when the user is trying to
|
||||
// navigate the queue.
|
||||
// Do not scroll to indices that are in the currently visible range. As that would
|
||||
// lead to the queue jumping around every time goto is called.
|
||||
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
|
||||
val start = lmm.findFirstCompletelyVisibleItemPosition()
|
||||
val end = lmm.findLastCompletelyVisibleItemPosition()
|
||||
|
|
|
@ -22,51 +22,88 @@ import android.os.SystemClock
|
|||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/** Represents a class capable of managing the internal player. */
|
||||
/**
|
||||
* An interface for internal audio playback. This can be used to coordinate what occurs in the
|
||||
* background playback task.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface InternalPlayer {
|
||||
/** The audio session ID of the player instance. */
|
||||
/** The ID of the audio session started by this instance. */
|
||||
val audioSessionId: Int
|
||||
|
||||
/** Whether the player should rewind instead of going to the previous song. */
|
||||
/** Whether the player should rewind before skipping back. */
|
||||
val shouldRewindWithPrev: Boolean
|
||||
|
||||
fun makeState(durationMs: Long): State
|
||||
|
||||
/** Called when a new song should be loaded into the player. */
|
||||
/**
|
||||
* Load a new [Song] into the internal player.
|
||||
* @param song The [Song] to load, or null if playback should stop entirely.
|
||||
* @param play Whether to start playing when the [Song] is loaded.
|
||||
*/
|
||||
fun loadSong(song: Song?, play: Boolean)
|
||||
|
||||
/** Seek to [positionMs] in the player. */
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
/** Called when the playing state needs to be changed. */
|
||||
fun changePlaying(isPlaying: Boolean)
|
||||
/**
|
||||
* Called when an [Action] has been queued and this [InternalPlayer] is available to handle it.
|
||||
* @param action The [Action] to perform.
|
||||
* @return true if the action was handled, false otherwise.
|
||||
*/
|
||||
fun performAction(action: Action): Boolean
|
||||
|
||||
/**
|
||||
* Called when [PlaybackStateManager] desires some [Action] to be completed. Returns true if the
|
||||
* action was consumed, false otherwise.
|
||||
* Get a [State] corresponding to the current player state.
|
||||
* @param durationMs The duration of the currently playing track, in milliseconds.
|
||||
* Required since the internal player cannot obtain an accurate duration itself.
|
||||
*/
|
||||
fun onAction(action: Action): Boolean
|
||||
fun getState(durationMs: Long): State
|
||||
|
||||
/**
|
||||
* Seek to a given position in the internal player.
|
||||
* @param positionMs The position to seek to, in milliseconds.
|
||||
*/
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
/**
|
||||
* Set whether the player should play or not.
|
||||
* @param isPlaying Whether to play or pause the current playback.
|
||||
*/
|
||||
fun setPlaying(isPlaying: Boolean)
|
||||
|
||||
/**
|
||||
* Possible long-running background tasks handled by the background playback task.
|
||||
*/
|
||||
sealed class Action {
|
||||
/** Restore the previously saved playback state. */
|
||||
object RestoreState : Action()
|
||||
|
||||
/**
|
||||
* Start shuffled playback of the entire music library.
|
||||
* Analogous to the "Shuffle All" shortcut.
|
||||
*/
|
||||
object ShuffleAll : Action()
|
||||
|
||||
/**
|
||||
* Start playing an audio file at the given [Uri].
|
||||
* @param uri The [Uri] of the audio file to start playing.
|
||||
*/
|
||||
data class Open(val uri: Uri) : Action()
|
||||
}
|
||||
|
||||
class State
|
||||
private constructor(
|
||||
/**
|
||||
* Whether the user has actually chosen to play this audio. The player might not actually be
|
||||
* playing at this time.
|
||||
*/
|
||||
/** Whether the player is actively playing audio or set to play audio in the future. */
|
||||
val isPlaying: Boolean,
|
||||
/** Whether the player is actually advancing through the audio. */
|
||||
/** Whether the player is actively playing audio in this moment. */
|
||||
private val isAdvancing: Boolean,
|
||||
/** The initial position at update time. */
|
||||
/** The position when this instance was created, in milliseconds. */
|
||||
private val initPositionMs: Long,
|
||||
/** The time this instance was created. */
|
||||
/** The time this instance was created, as a unix epoch timestamp. */
|
||||
private val creationTime: Long
|
||||
) {
|
||||
/**
|
||||
* Calculate the estimated position that the player is now at. If the player's position is
|
||||
* not advancing, this will be the initial position. Otherwise, this will be the position
|
||||
* plus the elapsed time since this state was uploaded.
|
||||
* Calculate the "real" playback position this instance contains, in milliseconds.
|
||||
* @return If paused, the original position will be returned. Otherwise, it will be
|
||||
* the original position plus the time elapsed since this state was created.
|
||||
*/
|
||||
fun calculateElapsedPosition() =
|
||||
fun calculateElapsedPositionMs() =
|
||||
if (isAdvancing) {
|
||||
initPositionMs + (SystemClock.elapsedRealtime() - creationTime)
|
||||
} else {
|
||||
|
@ -75,7 +112,11 @@ interface InternalPlayer {
|
|||
initPositionMs
|
||||
}
|
||||
|
||||
/** Load this state into the analogous [PlaybackStateCompat.Builder]. */
|
||||
/**
|
||||
* Load this instance into a [PlaybackStateCompat].
|
||||
* @param builder The [PlaybackStateCompat.Builder] to mutate.
|
||||
* @return The same [PlaybackStateCompat.Builder] for easy chaining.
|
||||
*/
|
||||
fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
|
||||
builder.setState(
|
||||
// State represents the user's preference, not the actual player state.
|
||||
|
@ -94,8 +135,8 @@ interface InternalPlayer {
|
|||
},
|
||||
creationTime)
|
||||
|
||||
// Equality ignores the creation time to prevent functionally
|
||||
// identical states from being equal.
|
||||
// Equality ignores the creation time to prevent functionally identical states
|
||||
// from being non-equal.
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
other is State &&
|
||||
|
@ -111,21 +152,20 @@ interface InternalPlayer {
|
|||
}
|
||||
|
||||
companion object {
|
||||
/** Create a new instance of this state. */
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param isPlaying Whether the player is actively playing audio or set to play audio
|
||||
* in the future.
|
||||
* @param isAdvancing Whether the player is actively playing audio in this moment.
|
||||
* @param positionMs The current position of the player.
|
||||
*/
|
||||
fun new(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
|
||||
State(
|
||||
isPlaying,
|
||||
// Minor sanity check: Make sure that advancing can't occur if the
|
||||
// main playing value is paused.
|
||||
// Minor sanity check: Make sure that advancing can't occur if already paused.
|
||||
isPlaying && isAdvancing,
|
||||
positionMs,
|
||||
SystemClock.elapsedRealtime())
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Action {
|
||||
object RestoreState : Action()
|
||||
object ShuffleAll : Action()
|
||||
data class Open(val uri: Uri) : Action()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,26 +21,39 @@ import android.content.ContentValues
|
|||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.provider.BaseColumns
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.queryAll
|
||||
import org.oxycblt.auxio.util.requireBackgroundThread
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists.
|
||||
* But that would needlessly bloat my app and has crippling bugs.
|
||||
* A [SQLiteDatabase] that persists the current playback state for future app lifecycles.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackStateDatabase private constructor(context: Context) :
|
||||
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
createTable(db, TABLE_STATE)
|
||||
createTable(db, TABLE_QUEUE)
|
||||
// Here, we have to split the database into two tables. One contains the queue with
|
||||
// an indefinite amount of items, and the other contains only one entry consisting
|
||||
// of the non-queue parts of the state, such as the playback position.
|
||||
db.createTable(TABLE_STATE) {
|
||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
||||
append("${StateColumns.INDEX} INTEGER NOT NULL,")
|
||||
append("${StateColumns.POSITION} LONG NOT NULL,")
|
||||
append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,")
|
||||
append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
|
||||
append("${StateColumns.SONG_UID} STRING,")
|
||||
append("${StateColumns.PARENT_UID} STRING")
|
||||
}
|
||||
|
||||
db.createTable(TABLE_QUEUE) {
|
||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
||||
append("${QueueColumns.SONG_UID} STRING NOT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
|
||||
|
@ -55,55 +68,28 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
// --- DATABASE CONSTRUCTION FUNCTIONS ---
|
||||
|
||||
/** Create a table for this database. */
|
||||
private fun createTable(db: SQLiteDatabase, name: String) {
|
||||
val command = StringBuilder()
|
||||
command.append("CREATE TABLE IF NOT EXISTS $name(")
|
||||
|
||||
if (name == TABLE_STATE) {
|
||||
constructStateTable(command)
|
||||
} else if (name == TABLE_QUEUE) {
|
||||
constructQueueTable(command)
|
||||
}
|
||||
|
||||
db.execSQL(command.toString())
|
||||
}
|
||||
|
||||
/** Construct a [StateColumns] table */
|
||||
private fun constructStateTable(command: StringBuilder) =
|
||||
command
|
||||
.append("${StateColumns.ID} LONG PRIMARY KEY,")
|
||||
.append("${StateColumns.SONG_UID} STRING,")
|
||||
.append("${StateColumns.POSITION} LONG NOT NULL,")
|
||||
.append("${StateColumns.PARENT_UID} STRING,")
|
||||
.append("${StateColumns.INDEX} INTEGER NOT NULL,")
|
||||
.append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
|
||||
.append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL)")
|
||||
|
||||
/** Construct a [QueueColumns] table */
|
||||
private fun constructQueueTable(command: StringBuilder) =
|
||||
command
|
||||
.append("${QueueColumns.ID} LONG PRIMARY KEY,")
|
||||
.append("${QueueColumns.SONG_UID} STRING NOT NULL)")
|
||||
|
||||
// --- INTERFACE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Read a persisted [SavedState] from the database.
|
||||
* @param library [MusicStore.Library] required to restore [SavedState].
|
||||
* @return A persisted [SavedState], or null if one could not be found.
|
||||
*/
|
||||
fun read(library: MusicStore.Library): SavedState? {
|
||||
requireBackgroundThread()
|
||||
|
||||
// Read the saved state and queue. If the state is non-null, that must imply an
|
||||
// existent, albeit possibly empty, queue.
|
||||
val rawState = readRawState() ?: return null
|
||||
val queue = readQueue(library)
|
||||
|
||||
// Correct the index to match up with a possibly shortened queue (file removals/changes)
|
||||
// Correct the index to match up with a queue that has possibly been shortened due to
|
||||
// song removals.
|
||||
var actualIndex = rawState.index
|
||||
while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) {
|
||||
actualIndex--
|
||||
}
|
||||
|
||||
// Restore parent item from the music library. If this fails, then the playback mode
|
||||
// reverts to "All Songs", which is considered okay.
|
||||
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
|
||||
|
||||
return SavedState(
|
||||
index = actualIndex,
|
||||
parent = parent,
|
||||
|
@ -113,22 +99,19 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
isShuffled = rawState.isShuffled)
|
||||
}
|
||||
|
||||
private fun readRawState(): RawState? {
|
||||
return readableDatabase.queryAll(TABLE_STATE) { cursor ->
|
||||
if (cursor.count == 0) {
|
||||
private fun readRawState() =
|
||||
readableDatabase.queryAll(TABLE_STATE) { cursor ->
|
||||
if (!cursor.moveToFirst()) {
|
||||
// Empty, nothing to do.
|
||||
return@queryAll null
|
||||
}
|
||||
|
||||
val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX)
|
||||
|
||||
val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION)
|
||||
val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE)
|
||||
val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED)
|
||||
val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID)
|
||||
val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID)
|
||||
|
||||
cursor.moveToFirst()
|
||||
|
||||
RawState(
|
||||
index = cursor.getInt(indexIndex),
|
||||
positionMs = cursor.getLong(posIndex),
|
||||
|
@ -139,15 +122,10 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
?: return@queryAll null,
|
||||
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
|
||||
}
|
||||
}
|
||||
|
||||
private fun readQueue(library: MusicStore.Library): MutableList<Song> {
|
||||
requireBackgroundThread()
|
||||
|
||||
private fun readQueue(library: MusicStore.Library): List<Song> {
|
||||
val queue = mutableListOf<Song>()
|
||||
|
||||
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
|
||||
if (cursor.count == 0) return@queryAll
|
||||
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
|
||||
while (cursor.moveToNext()) {
|
||||
val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue
|
||||
|
@ -157,15 +135,19 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
}
|
||||
|
||||
logD("Successfully read queue of ${queue.size} songs")
|
||||
|
||||
return queue
|
||||
}
|
||||
|
||||
/** Clear the previously written [SavedState] and write a new one. */
|
||||
/**
|
||||
* Clear the previous [SavedState] and write a new one.
|
||||
* @param state The new [SavedState] to write, or null to clear the database entirely.
|
||||
*/
|
||||
fun write(state: SavedState?) {
|
||||
requireBackgroundThread()
|
||||
|
||||
// Only bother saving a state if a song is actively playing from one.
|
||||
// This is not the case with a null state or a state with an out-of-bounds index.
|
||||
if (state != null && state.index in state.queue.indices) {
|
||||
// Transform saved state into raw state, which can then be written to the database.
|
||||
val rawState =
|
||||
RawState(
|
||||
index = state.index,
|
||||
|
@ -174,15 +156,14 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
isShuffled = state.isShuffled,
|
||||
songUid = state.queue[state.index].uid,
|
||||
parentUid = state.parent?.uid)
|
||||
|
||||
writeRawState(rawState)
|
||||
writeQueue(state.queue)
|
||||
logD("Wrote state")
|
||||
} else {
|
||||
writeRawState(null)
|
||||
writeQueue(null)
|
||||
logD("Cleared state")
|
||||
}
|
||||
|
||||
logD("Wrote state to database")
|
||||
}
|
||||
|
||||
private fun writeRawState(rawState: RawState?) {
|
||||
|
@ -192,7 +173,7 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
if (rawState != null) {
|
||||
val stateData =
|
||||
ContentValues(7).apply {
|
||||
put(StateColumns.ID, 0)
|
||||
put(BaseColumns._ID, 0)
|
||||
put(StateColumns.SONG_UID, rawState.songUid.toString())
|
||||
put(StateColumns.POSITION, rawState.positionMs)
|
||||
put(StateColumns.PARENT_UID, rawState.parentUid?.toString())
|
||||
|
@ -206,45 +187,25 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
/** Write a queue to the database. */
|
||||
private fun writeQueue(queue: List<Song>?) {
|
||||
val database = writableDatabase
|
||||
database.transaction { delete(TABLE_QUEUE, null, null) }
|
||||
|
||||
logD("Wiped queue db")
|
||||
|
||||
if (queue != null) {
|
||||
val idStart = queue.size
|
||||
logD("Beginning queue write [start: $idStart]")
|
||||
var position = 0
|
||||
|
||||
while (position < queue.size) {
|
||||
var i = position
|
||||
|
||||
database.transaction {
|
||||
while (i < queue.size) {
|
||||
val song = queue[i]
|
||||
i++
|
||||
|
||||
val itemData =
|
||||
ContentValues(2).apply {
|
||||
put(QueueColumns.ID, idStart + i)
|
||||
put(QueueColumns.SONG_UID, song.uid.toString())
|
||||
}
|
||||
|
||||
insert(TABLE_QUEUE, null, itemData)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the position at the end, if an insert failed at any point, then
|
||||
// the next iteration should skip it.
|
||||
position = i
|
||||
|
||||
logD("Wrote batch of songs. Position is now at $position")
|
||||
writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song ->
|
||||
ContentValues(2).apply {
|
||||
put(BaseColumns._ID, i)
|
||||
put(QueueColumns.SONG_UID, song.uid.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A condensed representation of the playback state that can be persisted.
|
||||
* @param index The position of the currently playing item in the queue. Can be -1 if the
|
||||
* persisted index no longer exists.
|
||||
* @param queue The [Song] queue.
|
||||
* @param parent The [MusicParent] item currently being played from
|
||||
* @param positionMs The current position in the currently played song, in ms
|
||||
* @param repeatMode The current [RepeatMode].
|
||||
* @param isShuffled Whether the queue is shuffled or not.
|
||||
*/
|
||||
data class SavedState(
|
||||
val index: Int,
|
||||
val queue: List<Song>,
|
||||
|
@ -254,40 +215,63 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
|||
val isShuffled: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* A lower-level form of [SavedState] that contains additional information to create
|
||||
* a more reliable restoration process.
|
||||
*/
|
||||
private data class RawState(
|
||||
/** @see SavedState.index */
|
||||
val index: Int,
|
||||
/** @see SavedState.positionMs */
|
||||
val positionMs: Long,
|
||||
/** @see SavedState.repeatMode */
|
||||
val repeatMode: RepeatMode,
|
||||
/** @see SavedState.isShuffled */
|
||||
val isShuffled: Boolean,
|
||||
/**
|
||||
* The [Music.UID] of the [Song] that was originally in the queue at [index].
|
||||
* This can be used to restore the currently playing item in the queue if
|
||||
* the index mapping changed.
|
||||
*/
|
||||
val songUid: Music.UID,
|
||||
/** @see SavedState.parent */
|
||||
val parentUid: Music.UID?
|
||||
)
|
||||
|
||||
/** Defines the columns used in the playback state table. */
|
||||
private object StateColumns {
|
||||
const val ID = "id"
|
||||
const val SONG_UID = "song_uid"
|
||||
const val POSITION = "position"
|
||||
const val PARENT_UID = "parent"
|
||||
/** @see RawState.index */
|
||||
const val INDEX = "queue_index"
|
||||
/** @see RawState.positionMs */
|
||||
const val POSITION = "position"
|
||||
/** @see RawState.isShuffled */
|
||||
const val IS_SHUFFLED = "is_shuffling"
|
||||
/** @see RawState.repeatMode */
|
||||
const val REPEAT_MODE = "repeat_mode"
|
||||
/** @see RawState.songUid */
|
||||
const val SONG_UID = "song_uid"
|
||||
/** @see RawState.parentUid */
|
||||
const val PARENT_UID = "parent"
|
||||
}
|
||||
|
||||
/** Defines the columns used in the queue table. */
|
||||
private object QueueColumns {
|
||||
const val ID = "id"
|
||||
/** @see Music.UID */
|
||||
const val SONG_UID = "song_uid"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DB_NAME = "auxio_playback_state.db"
|
||||
const val DB_VERSION = 8
|
||||
|
||||
const val TABLE_STATE = "playback_state"
|
||||
const val TABLE_QUEUE = "queue"
|
||||
private const val DB_NAME = "auxio_playback_state.db"
|
||||
private const val DB_VERSION = 8
|
||||
private const val TABLE_STATE = "playback_state"
|
||||
private const val TABLE_QUEUE = "queue"
|
||||
|
||||
@Volatile private var INSTANCE: PlaybackStateDatabase? = null
|
||||
|
||||
/** Get/Instantiate the single instance of [PlaybackStateDatabase]. */
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
*/
|
||||
fun getInstance(context: Context): PlaybackStateDatabase {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
|
|
|
@ -34,10 +34,10 @@ import org.oxycblt.auxio.util.logE
|
|||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* Core playback controller class.
|
||||
* Core playback state controller class.
|
||||
*
|
||||
* Whereas other apps centralize the playback state around the MediaSession, Auxio does not, as
|
||||
* MediaSession is poorly designed. We use our own playback state system instead.
|
||||
* MediaSession is poorly designed. This class instead ful-fills this role.
|
||||
*
|
||||
* This should ***NOT*** be used outside of the playback module.
|
||||
* - If you want to use the playback state in the UI, use
|
||||
|
@ -46,63 +46,62 @@ import org.oxycblt.auxio.util.logW
|
|||
* [org.oxycblt.auxio.playback.system.PlaybackService].
|
||||
*
|
||||
* Internal consumers should usually use [Callback], however the component that manages the player
|
||||
* itself should instead operate as a [InternalPlayer].
|
||||
* itself should instead use [InternalPlayer].
|
||||
*
|
||||
* All access should be done with [PlaybackStateManager.getInstance].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackStateManager private constructor() {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val callbacks = mutableListOf<Callback>()
|
||||
private var internalPlayer: InternalPlayer? = null
|
||||
private var pendingAction: InternalPlayer.Action? = null
|
||||
|
||||
/** The currently playing song. Null if there isn't one */
|
||||
/** The currently playing [Song]. Null if nothing is playing. */
|
||||
val song
|
||||
get() = queue.getOrNull(index)
|
||||
|
||||
/** The parent the queue is based on, null if all songs */
|
||||
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
||||
var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
private var _queue = mutableListOf<Song>()
|
||||
|
||||
private val orderedQueue = listOf<Song>()
|
||||
private val shuffledQueue = listOf<Song>()
|
||||
|
||||
/** The current queue determined by [parent] */
|
||||
/** The current queue. */
|
||||
val queue
|
||||
get() = _queue
|
||||
|
||||
/** The current position in the queue */
|
||||
/** The position of the currently playing item in the queue. */
|
||||
var index = -1
|
||||
private set
|
||||
|
||||
/** The current state of the internal player. */
|
||||
/** The current [InternalPlayer] state. */
|
||||
var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0)
|
||||
private set
|
||||
|
||||
/** The current [RepeatMode] */
|
||||
var repeatMode = RepeatMode.NONE
|
||||
set(value) {
|
||||
field = value
|
||||
notifyRepeatModeChanged()
|
||||
}
|
||||
|
||||
/** Whether the queue is shuffled */
|
||||
/** Whether the queue is shuffled. */
|
||||
var isShuffled = false
|
||||
private set
|
||||
|
||||
/** Whether this instance has played something or restored a state. */
|
||||
/** Whether this instance has played something. */
|
||||
var isInitialized = false
|
||||
private set
|
||||
|
||||
/** The current audio session ID of the internal player. Null if no internal player present. */
|
||||
/**
|
||||
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
|
||||
* available.
|
||||
*/
|
||||
val currentAudioSessionId: Int?
|
||||
get() = internalPlayer?.audioSessionId
|
||||
|
||||
/** An action that is awaiting the internal player instance to consume it. */
|
||||
private var pendingAction: InternalPlayer.Action? = null
|
||||
|
||||
/** Add a callback to this instance. Make sure to remove it when done. */
|
||||
/**
|
||||
* Add a [Callback] to this instance. This can be used to receive changes in the playback
|
||||
* state. Will immediately invoke [Callback] methods to initialize the instance with the
|
||||
* current state.
|
||||
* @param callback The [Callback] to add.
|
||||
* @see Callback
|
||||
*/
|
||||
@Synchronized
|
||||
fun addCallback(callback: Callback) {
|
||||
if (isInitialized) {
|
||||
|
@ -115,13 +114,23 @@ class PlaybackStateManager private constructor() {
|
|||
callbacks.add(callback)
|
||||
}
|
||||
|
||||
/** Remove a [Callback] bound to this instance. */
|
||||
/**
|
||||
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
|
||||
* @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
|
||||
* the first place.
|
||||
* @see Callback
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeCallback(callback: Callback) {
|
||||
callbacks.remove(callback)
|
||||
}
|
||||
|
||||
/** Register a [InternalPlayer] with this instance. */
|
||||
/**
|
||||
* Register an [InternalPlayer] for this instance. This instance will handle translating the
|
||||
* current playback state into audio playback. There can be only one [InternalPlayer] at a time.
|
||||
* Will invoke [InternalPlayer] methods to initialize the instance with the current state.
|
||||
* @param internalPlayer The [InternalPlayer] to register. Will do nothing if already registered.
|
||||
*/
|
||||
@Synchronized
|
||||
fun registerInternalPlayer(internalPlayer: InternalPlayer) {
|
||||
if (BuildConfig.DEBUG && this.internalPlayer != null) {
|
||||
|
@ -131,15 +140,22 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
if (isInitialized) {
|
||||
internalPlayer.loadSong(song, playerState.isPlaying)
|
||||
internalPlayer.seekTo(playerState.calculateElapsedPosition())
|
||||
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
|
||||
// See if there's any action that has been queued.
|
||||
requestAction(internalPlayer)
|
||||
// Once initialized, try to synchronize with the player state it has created.
|
||||
synchronizeState(internalPlayer)
|
||||
}
|
||||
|
||||
this.internalPlayer = internalPlayer
|
||||
}
|
||||
|
||||
/** Unregister a [InternalPlayer] with this instance. */
|
||||
/**
|
||||
* Unregister the [InternalPlayer] from this instance, prevent it from recieving any further
|
||||
* commands.
|
||||
* @param internalPlayer The [InternalPlayer] to unregister. Must be the current
|
||||
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
|
||||
*/
|
||||
@Synchronized
|
||||
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
|
||||
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
|
||||
|
@ -152,7 +168,15 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
// --- PLAYING FUNCTIONS ---
|
||||
|
||||
/** Play a song from a parent that contains the song. */
|
||||
/**
|
||||
* Start new playback.
|
||||
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
|
||||
* @param parent The [MusicParent] to play from, or null if to play from the entire
|
||||
* [MusicStore.Library].
|
||||
* @param settings [Settings] required to configure the queue.
|
||||
* @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle"
|
||||
* configuration.
|
||||
*/
|
||||
@Synchronized
|
||||
fun play(
|
||||
song: Song?,
|
||||
|
@ -162,26 +186,27 @@ class PlaybackStateManager private constructor() {
|
|||
) {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
val library = musicStore.library ?: return
|
||||
|
||||
// Setup parent and queue
|
||||
this.parent = parent
|
||||
_queue = (parent?.songs ?: library.songs).toMutableList()
|
||||
orderQueue(settings, shuffled, song)
|
||||
|
||||
// Notify components of changes
|
||||
notifyNewPlayback()
|
||||
notifyShuffledChanged()
|
||||
|
||||
internalPlayer.loadSong(this.song, true)
|
||||
|
||||
// Played something, so we are initialized now
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
||||
/** Go to the next song, along with doing all the checks that entails. */
|
||||
/**
|
||||
* Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there
|
||||
* is no [Song] ahead to skip to.
|
||||
*/
|
||||
@Synchronized
|
||||
fun next() {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
|
||||
// Increment the index, if it cannot be incremented any further, then
|
||||
// repeat and pause/resume playback depending on the setting
|
||||
if (index < _queue.lastIndex) {
|
||||
|
@ -191,7 +216,10 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Go to the previous song, doing any checks that are needed. */
|
||||
/**
|
||||
* Go to the previous [Song] in the queue. Will rewind if there are no previous [Song]s
|
||||
* to skip to, or if configured to do so.
|
||||
*/
|
||||
@Synchronized
|
||||
fun prev() {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
|
@ -199,12 +227,16 @@ class PlaybackStateManager private constructor() {
|
|||
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
|
||||
if (internalPlayer.shouldRewindWithPrev) {
|
||||
rewind()
|
||||
changePlaying(true)
|
||||
setPlaying(true)
|
||||
} else {
|
||||
gotoImpl(internalPlayer, max(index - 1, 0), true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a [Song] at the given position in the queue.
|
||||
* @param index The position of the [Song] in the queue to start playing.
|
||||
*/
|
||||
@Synchronized
|
||||
fun goto(index: Int) {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
|
@ -217,35 +249,51 @@ class PlaybackStateManager private constructor() {
|
|||
internalPlayer.loadSong(song, play)
|
||||
}
|
||||
|
||||
/** Add a [song] to the top of the queue. */
|
||||
/**
|
||||
* Add a [Song] to the top of the queue.
|
||||
* @param song The [Song] to add.
|
||||
*/
|
||||
@Synchronized
|
||||
fun playNext(song: Song) {
|
||||
_queue.add(index + 1, song)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Add a list of [songs] to the top of the queue. */
|
||||
/**
|
||||
* Add [Song]s to the top of the queue.
|
||||
* @param songs The [Song]s to add.
|
||||
*/
|
||||
@Synchronized
|
||||
fun playNext(songs: List<Song>) {
|
||||
_queue.addAll(index + 1, songs)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Add a [song] to the end of the queue. */
|
||||
/**
|
||||
* Add a [Song] to the end of the queue.
|
||||
* @param song The [Song] to add.
|
||||
*/
|
||||
@Synchronized
|
||||
fun addToQueue(song: Song) {
|
||||
_queue.add(song)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Add a list of [songs] to the end of the queue. */
|
||||
/**
|
||||
* Add [Song]s to the end of the queue.
|
||||
* @param songs The [Song]s to add.
|
||||
*/
|
||||
@Synchronized
|
||||
fun addToQueue(songs: List<Song>) {
|
||||
_queue.addAll(songs)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Move a queue item at [from] to a position at [to]. Will ignore invalid indexes. */
|
||||
/**
|
||||
* Move a [Song] in the queue.
|
||||
* @param from The position of the [Song] to move in the queue.
|
||||
* @param to The destination position in the queue.
|
||||
*/
|
||||
@Synchronized
|
||||
fun moveQueueItem(from: Int, to: Int) {
|
||||
logD("Moving item $from to position $to")
|
||||
|
@ -253,7 +301,10 @@ class PlaybackStateManager private constructor() {
|
|||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Remove a queue item at [index]. Will ignore invalid indexes. */
|
||||
/**
|
||||
* Remove a [Song] from the queue.
|
||||
* @param index The position of the [Song] to remove in the queue.
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeQueueItem(index: Int) {
|
||||
logD("Removing item ${_queue[index].rawName}")
|
||||
|
@ -261,7 +312,11 @@ class PlaybackStateManager private constructor() {
|
|||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Set whether this instance is [shuffled]. Updates the queue accordingly. */
|
||||
/**
|
||||
* (Re)shuffle or (Re)order this instance.
|
||||
* @param shuffled Whether to shuffle the queue or not.
|
||||
* @param settings [Settings] required to configure the queue.
|
||||
*/
|
||||
@Synchronized
|
||||
fun reshuffle(shuffled: Boolean, settings: Settings) {
|
||||
val song = song ?: return
|
||||
|
@ -270,18 +325,26 @@ class PlaybackStateManager private constructor() {
|
|||
notifyShuffledChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-configure the queue.
|
||||
* @param settings [Settings] required to configure the queue.
|
||||
* @param shuffled Whether to shuffle the queue or not.
|
||||
* @param keep the [Song] to start at in the new queue, or null if not specified.
|
||||
*/
|
||||
private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) {
|
||||
val newIndex: Int
|
||||
|
||||
if (shuffled) {
|
||||
// Shuffling queue, randomize the current song list and move the Song to play
|
||||
// to the start.
|
||||
_queue.shuffle()
|
||||
|
||||
if (keep != null) {
|
||||
_queue.add(0, _queue.removeAt(_queue.indexOf(keep)))
|
||||
}
|
||||
|
||||
newIndex = 0
|
||||
} else {
|
||||
// Ordering queue, re-sort it using the analogous parent sort configuration and
|
||||
// then jump to the Song to play.
|
||||
// TODO: Rework queue system to avoid having to do this
|
||||
val sort =
|
||||
parent.let { parent ->
|
||||
when (parent) {
|
||||
|
@ -291,7 +354,6 @@ class PlaybackStateManager private constructor() {
|
|||
is Genre -> settings.detailGenreSort
|
||||
}
|
||||
}
|
||||
|
||||
sort.songsInPlace(_queue)
|
||||
newIndex = keep?.let(_queue::indexOf) ?: 0
|
||||
}
|
||||
|
@ -303,6 +365,11 @@ class PlaybackStateManager private constructor() {
|
|||
|
||||
// --- INTERNAL PLAYER FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Synchronize the state of this instance with the current [InternalPlayer].
|
||||
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
|
||||
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
|
||||
*/
|
||||
@Synchronized
|
||||
fun synchronizeState(internalPlayer: InternalPlayer) {
|
||||
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
|
||||
|
@ -310,23 +377,32 @@ class PlaybackStateManager private constructor() {
|
|||
return
|
||||
}
|
||||
|
||||
val newState = internalPlayer.makeState(song?.durationMs ?: 0)
|
||||
val newState = internalPlayer.getState(song?.durationMs ?: 0)
|
||||
if (newState != playerState) {
|
||||
playerState = newState
|
||||
notifyStateChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually.
|
||||
* @param action The [InternalPlayer.Action] to perform.
|
||||
*/
|
||||
@Synchronized
|
||||
fun startAction(action: InternalPlayer.Action) {
|
||||
val internalPlayer = internalPlayer
|
||||
if (internalPlayer == null || !internalPlayer.onAction(action)) {
|
||||
if (internalPlayer == null || !internalPlayer.performAction(action)) {
|
||||
logD("Internal player not present or did not consume action, waiting")
|
||||
pendingAction = action
|
||||
}
|
||||
}
|
||||
|
||||
/** Request the stored [InternalPlayer.Action] */
|
||||
/**
|
||||
* Request that the pending [InternalPlayer.Action] (if any) be passed to the given
|
||||
* [InternalPlayer].
|
||||
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
|
||||
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
|
||||
*/
|
||||
@Synchronized
|
||||
fun requestAction(internalPlayer: InternalPlayer) {
|
||||
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
|
||||
|
@ -334,34 +410,45 @@ class PlaybackStateManager private constructor() {
|
|||
return
|
||||
}
|
||||
|
||||
if (pendingAction?.let(internalPlayer::onAction) == true) {
|
||||
if (pendingAction?.let(internalPlayer::performAction) == true) {
|
||||
logD("Pending action consumed")
|
||||
pendingAction = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the current playing state. */
|
||||
fun changePlaying(isPlaying: Boolean) {
|
||||
internalPlayer?.changePlaying(isPlaying)
|
||||
/**
|
||||
* Update whether playback is ongoing or not.
|
||||
* @param isPlaying Whether playback is ongoing or not.
|
||||
*/
|
||||
fun setPlaying(isPlaying: Boolean) {
|
||||
internalPlayer?.setPlaying(isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* **Seek** to a [positionMs].
|
||||
* @param positionMs The position to seek to in millis.
|
||||
* Seek to the given position in the currently playing [Song].
|
||||
* @param positionMs The position to seek to, in milliseconds.
|
||||
*/
|
||||
@Synchronized
|
||||
fun seekTo(positionMs: Long) {
|
||||
internalPlayer?.seekTo(positionMs)
|
||||
}
|
||||
|
||||
/** Rewind to the beginning of a song. */
|
||||
/**
|
||||
* Rewind to the beginning of the currently playing [Song].
|
||||
*/
|
||||
fun rewind() = seekTo(0)
|
||||
|
||||
// --- PERSISTENCE FUNCTIONS ---
|
||||
|
||||
/** Restore the state from the [database]. Returns if a state was restored. */
|
||||
/**
|
||||
* Restore the previously saved state (if any) and apply it to the playback state.
|
||||
* @param database The [PlaybackStateDatabase] to load from.
|
||||
* @param force Whether to force a restore regardless of the current state.
|
||||
* @return If the state was restored, false otherwise.
|
||||
*/
|
||||
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
|
||||
if (isInitialized && !force) {
|
||||
// Already initialized and not forcing a restore, nothing to do.
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -376,10 +463,12 @@ class PlaybackStateManager private constructor() {
|
|||
return false
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
// Translate the state we have just read into a usable playback state for this
|
||||
// instance.
|
||||
return synchronized(this) {
|
||||
// State could have changed while we were loading, so check if we were initialized
|
||||
// now before applying the state.
|
||||
if (state != null && (!isInitialized || force)) {
|
||||
// Continuing playback while also possibly doing drastic state updates is
|
||||
// a bad idea, so pause.
|
||||
index = state.index
|
||||
parent = state.parent
|
||||
_queue = state.queue.toMutableList()
|
||||
|
@ -390,23 +479,36 @@ class PlaybackStateManager private constructor() {
|
|||
notifyRepeatModeChanged()
|
||||
notifyShuffledChanged()
|
||||
|
||||
// Continuing playback after drastic state updates is a bad idea, so pause.
|
||||
internalPlayer.loadSong(song, false)
|
||||
internalPlayer.seekTo(state.positionMs)
|
||||
|
||||
isInitialized = true
|
||||
|
||||
return true
|
||||
true
|
||||
} else {
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the current state to the [database]. */
|
||||
/**
|
||||
* Save the current state.
|
||||
* @param database The [PlaybackStateDatabase] to save the state to.
|
||||
* @return If state was saved, false otherwise.
|
||||
*/
|
||||
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
|
||||
logD("Saving state to DB")
|
||||
|
||||
val state = synchronized(this) { makeStateImpl() }
|
||||
// Create the saved state from the current playback state.
|
||||
val state = synchronized(this) {
|
||||
PlaybackStateDatabase.SavedState(
|
||||
index = index,
|
||||
parent = parent,
|
||||
queue = _queue,
|
||||
positionMs = playerState.calculateElapsedPositionMs(),
|
||||
isShuffled = isShuffled,
|
||||
repeatMode = repeatMode) }
|
||||
return try {
|
||||
withContext(Dispatchers.IO) { database.write(state) }
|
||||
true
|
||||
|
@ -417,7 +519,11 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Wipe the current state. */
|
||||
/**
|
||||
* Clear the current state.
|
||||
* @param database The [PlaybackStateDatabase] to clear te state from
|
||||
* @return If the state was cleared, false otherwise.
|
||||
*/
|
||||
suspend fun wipeState(database: PlaybackStateDatabase): Boolean {
|
||||
logD("Wiping state")
|
||||
|
||||
|
@ -431,10 +537,14 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Sanitize the state with [newLibrary]. */
|
||||
/**
|
||||
* Update the playback state to align with a new [MusicStore.Library].
|
||||
* @param newLibrary The new [MusicStore.Library] that was recently loaded.
|
||||
*/
|
||||
@Synchronized
|
||||
fun sanitize(newLibrary: MusicStore.Library) {
|
||||
if (!isInitialized) {
|
||||
// Nothing playing, nothing to do.
|
||||
logD("Not initialized, no need to sanitize")
|
||||
return
|
||||
}
|
||||
|
@ -446,9 +556,7 @@ class PlaybackStateManager private constructor() {
|
|||
// While we could just save and reload the state, we instead sanitize the state
|
||||
// at runtime for better performance (and to sidestep a co-routine on behalf of the caller).
|
||||
|
||||
val oldSongUid = song?.uid
|
||||
val oldPosition = playerState.calculateElapsedPosition()
|
||||
|
||||
// Sanitize parent
|
||||
parent =
|
||||
parent?.let {
|
||||
when (it) {
|
||||
|
@ -458,33 +566,26 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
// Sanitize queue. Make sure we re-align the index to point to the previously playing
|
||||
// Song in the queue queue.
|
||||
val oldSongUid = song?.uid
|
||||
_queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
|
||||
|
||||
while (song?.uid != oldSongUid && index > -1) {
|
||||
index--
|
||||
}
|
||||
|
||||
notifyNewPlayback()
|
||||
|
||||
val oldPosition = playerState.calculateElapsedPositionMs()
|
||||
// Continuing playback while also possibly doing drastic state updates is
|
||||
// a bad idea, so pause.
|
||||
internalPlayer.loadSong(song, false)
|
||||
|
||||
if (index > -1) {
|
||||
// Internal player may have reloaded the media item, re-seek to the previous position
|
||||
seekTo(oldPosition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeStateImpl() =
|
||||
PlaybackStateDatabase.SavedState(
|
||||
index = index,
|
||||
parent = parent,
|
||||
queue = _queue,
|
||||
positionMs = playerState.calculateElapsedPosition(),
|
||||
isShuffled = isShuffled,
|
||||
repeatMode = repeatMode)
|
||||
|
||||
// --- CALLBACKS ---
|
||||
|
||||
private fun notifyIndexMoved() {
|
||||
|
@ -530,36 +631,66 @@ class PlaybackStateManager private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* The interface for receiving updates from [PlaybackStateManager]. Add the callback to
|
||||
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
|
||||
* [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
|
||||
*/
|
||||
interface Callback {
|
||||
/** Called when the index is moved, but the queue does not change. This changes the song. */
|
||||
/**
|
||||
* Called when the position of the currently playing item has changed, changing the
|
||||
* current [Song], but no other queue attribute has changed.
|
||||
* @param index The new position in the queue.
|
||||
*/
|
||||
fun onIndexMoved(index: Int) {}
|
||||
|
||||
/** Called when the queue has changed in a way that does not change the index or song. */
|
||||
/**
|
||||
* Called when the queue changed in a trivial manner, such as a move.
|
||||
* @param queue The new queue.
|
||||
*/
|
||||
fun onQueueChanged(queue: List<Song>) {}
|
||||
|
||||
/** Called when the queue and index has changed, but the song has not changed. */
|
||||
/**
|
||||
* Called when the queue has changed in a non-trivial manner (such as re-shuffling),
|
||||
* but the currently playing [Song] has not.
|
||||
* @param index The new position in the queue.
|
||||
*/
|
||||
fun onQueueReworked(index: Int, queue: List<Song>) {}
|
||||
|
||||
/** Called when playback is changed completely, with a new index, queue, and parent. */
|
||||
/**
|
||||
* Called when a new playback configuration was created.
|
||||
* @param index The new position in the queue.
|
||||
* @param queue The new queue.
|
||||
* @param parent The new [MusicParent] being played from, or null if playing from all
|
||||
* songs.
|
||||
*/
|
||||
fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {}
|
||||
|
||||
/** Called when the state of the internal player changes. */
|
||||
/**
|
||||
* Called when the state of the [InternalPlayer] changes.
|
||||
* @param state The new state of the [InternalPlayer].
|
||||
*/
|
||||
fun onStateChanged(state: InternalPlayer.State) {}
|
||||
|
||||
/** Called when the repeat mode is changed. */
|
||||
/**
|
||||
* Called when the [RepeatMode] changes.
|
||||
* @param repeatMode The new [RepeatMode].
|
||||
*/
|
||||
fun onRepeatChanged(repeatMode: RepeatMode) {}
|
||||
|
||||
/** Called when the shuffled state is changed. */
|
||||
/**
|
||||
* Called when the queue's shuffle state changes. Handling the queue change itself
|
||||
* should occur in [onQueueReworked],
|
||||
* @param isShuffled Whether the queue is shuffled.
|
||||
*/
|
||||
fun onShuffledChanged(isShuffled: Boolean) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: PlaybackStateManager? = null
|
||||
|
||||
/** Get/Instantiate the single instance of [PlaybackStateManager]. */
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
*/
|
||||
fun getInstance(): PlaybackStateManager {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
|
|
|
@ -21,15 +21,31 @@ import org.oxycblt.auxio.IntegerTable
|
|||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* Enum that determines the playback repeat mode.
|
||||
* Represents the current repeat mode of the player.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class RepeatMode {
|
||||
/**
|
||||
* Do not repeat. Songs are played immediately, and playback is paused when the queue repeats.
|
||||
*/
|
||||
NONE,
|
||||
|
||||
/**
|
||||
* Repeat the whole queue. Songs are played immediately, and playback continues when the
|
||||
* queue repeats.
|
||||
*/
|
||||
ALL,
|
||||
|
||||
/**
|
||||
* Repeat the current song. A Song will be continuously played until skipped. If configured,
|
||||
* playback may pause when a Song repeats.
|
||||
*/
|
||||
TRACK;
|
||||
|
||||
/** Increment the mode, e.g from [NONE] to [ALL] */
|
||||
/**
|
||||
* Increment the mode.
|
||||
* @return If [NONE], [ALL]. If [ALL], [TRACK]. If [TRACK], [NONE].
|
||||
*/
|
||||
fun increment() =
|
||||
when (this) {
|
||||
NONE -> ALL
|
||||
|
@ -37,7 +53,10 @@ enum class RepeatMode {
|
|||
TRACK -> NONE
|
||||
}
|
||||
|
||||
/** The icon representing this particular mode. */
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
* @see fromIntCode
|
||||
*/
|
||||
val icon: Int
|
||||
get() =
|
||||
when (this) {
|
||||
|
@ -56,9 +75,14 @@ enum class RepeatMode {
|
|||
}
|
||||
|
||||
companion object {
|
||||
/** Convert an int [code] into an instance, or null if it isn't valid. */
|
||||
fun fromIntCode(code: Int) =
|
||||
when (code) {
|
||||
/**
|
||||
* Convert a [RepeatMode] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [RepeatMode]
|
||||
* @return The corresponding [RepeatMode], or null if the [RepeatMode] is invalid.
|
||||
* @see RepeatMode.intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.REPEAT_MODE_NONE -> NONE
|
||||
IntegerTable.REPEAT_MODE_ALL -> ALL
|
||||
IntegerTable.REPEAT_MODE_TRACK -> TRACK
|
||||
|
|
|
@ -23,8 +23,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] that handles connections from bluetooth headsets, starting playback if they
|
||||
* occur.
|
||||
* A [BroadcastReceiver] that starts music playback when a bluetooth headset is connected.
|
||||
* @author seijikun, OxygenCobalt
|
||||
*/
|
||||
class BluetoothHeadsetReceiver : BroadcastReceiver() {
|
||||
|
|
|
@ -25,12 +25,8 @@ import androidx.core.content.ContextCompat
|
|||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
|
||||
/**
|
||||
* Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON intent
|
||||
* to determine the media apps on a system. *Auxio does not expose this.* Auxio exposes a
|
||||
* MediaSession that an app should control instead through the much better MediaController API. But
|
||||
* who cares about that, we need to make sure the 3% of barely functioning TouchWiz devices running
|
||||
* KitKat don't break! To prevent Auxio from not showing up at all in these apps, we declare a
|
||||
* BroadcastReceiver in the manifest that hacks in this functionality.
|
||||
* A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MediaButtonReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
|
|
|
@ -40,24 +40,14 @@ import org.oxycblt.auxio.settings.Settings
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The component managing the [MediaSessionCompat] instance, alongside the [NotificationComponent].
|
||||
*
|
||||
* Auxio does not directly rely on MediaSession, as it is extremely poorly designed. We instead just
|
||||
* mirror the playback state into the media session.
|
||||
*
|
||||
* A component that mirrors the current playback state into the [MediaSessionCompat] and
|
||||
* [NotificationComponent].
|
||||
* @param context [Context] required to initialize components.
|
||||
* @param callback [Callback] to forward notification updates to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MediaSessionComponent(private val context: Context, private val callback: Callback) :
|
||||
MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
|
||||
interface Callback {
|
||||
fun onPostNotification(notification: NotificationComponent?, reason: PostingReason)
|
||||
}
|
||||
|
||||
enum class PostingReason {
|
||||
METADATA,
|
||||
ACTIONS
|
||||
}
|
||||
|
||||
private val mediaSession =
|
||||
MediaSessionCompat(context, context.packageName).apply {
|
||||
isActive = true
|
||||
|
@ -75,15 +65,22 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
mediaSession.setCallback(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a system media button [Intent] to the [MediaSessionCompat].
|
||||
* @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward.
|
||||
*/
|
||||
fun handleMediaButtonIntent(intent: Intent) {
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, closing the [MediaSessionCompat] and preventing any
|
||||
* further updates to the [NotificationComponent].
|
||||
*/
|
||||
fun release() {
|
||||
provider.release()
|
||||
settings.release()
|
||||
playbackManager.removeCallback(this)
|
||||
|
||||
mediaSession.apply {
|
||||
isActive = false
|
||||
release()
|
||||
|
@ -112,91 +109,11 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
invalidateSessionState()
|
||||
}
|
||||
|
||||
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
|
||||
if (song == null) {
|
||||
mediaSession.setMetadata(emptyMetadata)
|
||||
callback.onPostNotification(null, PostingReason.METADATA)
|
||||
return
|
||||
}
|
||||
|
||||
// Note: We would leave the artist field null if it didn't exist and let downstream
|
||||
// consumers handle it, but that would break the notification display.
|
||||
val title = song.resolveName(context)
|
||||
val artist = song.resolveArtistContents(context)
|
||||
val builder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
|
||||
song.album.resolveArtistContents(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
|
||||
.putText(
|
||||
METADATA_KEY_PARENT,
|
||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
|
||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
|
||||
|
||||
song.track?.let {
|
||||
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
|
||||
}
|
||||
|
||||
song.disc?.let {
|
||||
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong())
|
||||
}
|
||||
|
||||
song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) }
|
||||
|
||||
// We are normally supposed to use URIs for album art, but that removes some of the
|
||||
// nice things we can do like square cropping or high quality covers. Instead,
|
||||
// we load a full-size bitmap into the media session and take the performance hit.
|
||||
provider.load(
|
||||
song,
|
||||
object : BitmapProvider.Target {
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
val metadata = builder.build()
|
||||
mediaSession.setMetadata(metadata)
|
||||
notification.updateMetadata(metadata)
|
||||
callback.onPostNotification(notification, PostingReason.METADATA)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateQueue(queue: List<Song>) {
|
||||
val queueItems =
|
||||
queue.mapIndexed { i, song ->
|
||||
// Since we usually have to load many songs into the queue, use the MediaStore URI
|
||||
// instead of loading a bitmap.
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
.setMediaId(song.uid.toString())
|
||||
.setTitle(song.resolveName(context))
|
||||
.setSubtitle(song.resolveArtistContents(context))
|
||||
.setIconUri(song.album.coverUri)
|
||||
.setMediaUri(song.uri)
|
||||
.build()
|
||||
|
||||
MediaSessionCompat.QueueItem(description, i.toLong())
|
||||
}
|
||||
|
||||
mediaSession.setQueue(queueItems)
|
||||
}
|
||||
|
||||
override fun onStateChanged(state: InternalPlayer.State) {
|
||||
invalidateSessionState()
|
||||
notification.updatePlaying(playbackManager.playerState.isPlaying)
|
||||
if (!provider.isBusy) {
|
||||
callback.onPostNotification(notification, PostingReason.ACTIONS)
|
||||
callback.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -260,11 +177,11 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
}
|
||||
|
||||
override fun onPlay() {
|
||||
playbackManager.changePlaying(true)
|
||||
playbackManager.setPlaying(true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
playbackManager.changePlaying(false)
|
||||
playbackManager.setPlaying(false)
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
|
@ -285,7 +202,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
|
||||
override fun onRewind() {
|
||||
playbackManager.rewind()
|
||||
playbackManager.changePlaying(true)
|
||||
playbackManager.setPlaying(true)
|
||||
}
|
||||
|
||||
override fun onSetRepeatMode(repeatMode: Int) {
|
||||
|
@ -324,24 +241,117 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
|
||||
}
|
||||
|
||||
// --- MISC ---
|
||||
// --- INTERNAL ---
|
||||
|
||||
/**
|
||||
* Upload a new [MediaMetadataCompat] based on the current playback state to the
|
||||
* [MediaSessionCompat] and [NotificationComponent].
|
||||
* @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no
|
||||
* [Song] is currently playing.
|
||||
* @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null
|
||||
* if playback is currently occuring from all songs.
|
||||
*/
|
||||
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
|
||||
if (song == null) {
|
||||
// Nothing playing, reset the MediaSession and close the notification.
|
||||
mediaSession.setMetadata(emptyMetadata)
|
||||
return
|
||||
}
|
||||
|
||||
// Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
|
||||
// several times.
|
||||
val title = song.resolveName(context)
|
||||
val artist = song.resolveArtistContents(context)
|
||||
val builder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
|
||||
// Note: We would leave the artist field null if it didn't exist and let downstream
|
||||
// consumers handle it, but that would break the notification display.
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
|
||||
song.album.resolveArtistContents(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
|
||||
.putText(
|
||||
METADATA_KEY_PARENT,
|
||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
|
||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
|
||||
// These fields are nullable and so we must check first before adding them to the fields.
|
||||
song.track?.let {
|
||||
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
|
||||
}
|
||||
song.disc?.let {
|
||||
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong())
|
||||
}
|
||||
song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) }
|
||||
|
||||
// We are normally supposed to use URIs for album art, but that removes some of the
|
||||
// nice things we can do like square cropping or high quality covers. Instead,
|
||||
// we load a full-size bitmap into the media session and take the performance hit.
|
||||
provider.load(
|
||||
song,
|
||||
object : BitmapProvider.Target {
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
val metadata = builder.build()
|
||||
mediaSession.setMetadata(metadata)
|
||||
notification.updateMetadata(metadata)
|
||||
callback.onPostNotification(notification)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a new queue to the [MediaSessionCompat].
|
||||
* @param queue The current queue to upload.
|
||||
*/
|
||||
private fun updateQueue(queue: List<Song>) {
|
||||
val queueItems =
|
||||
queue.mapIndexed { i, song ->
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
// Media ID should not be the item index but rather the UID,
|
||||
// as it's used to request a song to be played from the queue.
|
||||
.setMediaId(song.uid.toString())
|
||||
.setTitle(song.resolveName(context))
|
||||
.setSubtitle(song.resolveArtistContents(context))
|
||||
// Since we usually have to load many songs into the queue, use the
|
||||
// MediaStore URI instead of loading a bitmap.
|
||||
.setIconUri(song.album.coverUri)
|
||||
.setMediaUri(song.uri)
|
||||
.build()
|
||||
// Store the item index so we can then use the analogous index in the
|
||||
// playback state.
|
||||
MediaSessionCompat.QueueItem(description, i.toLong())
|
||||
}
|
||||
mediaSession.setQueue(queueItems)
|
||||
}
|
||||
|
||||
/** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
|
||||
private fun invalidateSessionState() {
|
||||
logD("Updating media session playback state")
|
||||
|
||||
// Note: Due to metadata updates being delayed but playback remaining ongoing, the position
|
||||
// will be wonky until we can upload a duration. Again, this ties back to how I must
|
||||
// aggressively batch notification updates to prevent rate-limiting.
|
||||
val state =
|
||||
PlaybackStateCompat.Builder()
|
||||
// InternalPlayer.State handles position/state information.
|
||||
playbackManager.playerState.intoPlaybackState(PlaybackStateCompat.Builder())
|
||||
.setActions(ACTIONS)
|
||||
// Active queue ID corresponds to the indices we populated prior, use them here.
|
||||
.setActiveQueueItemId(playbackManager.index.toLong())
|
||||
|
||||
playbackManager.playerState.intoPlaybackState(state)
|
||||
// Android 13+ relies on custom actions in the notification.
|
||||
|
||||
// Android 13+ leverages custom actions in the notification.
|
||||
|
||||
val extraAction =
|
||||
// Add the secondary action (either repeat/shuffle depending on the configuration)
|
||||
val secondaryAction =
|
||||
when (settings.playbackNotificationAction) {
|
||||
ActionMode.SHUFFLE ->
|
||||
PlaybackStateCompat.CustomAction.Builder(
|
||||
|
@ -358,20 +368,21 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
context.getString(R.string.desc_change_repeat),
|
||||
playbackManager.repeatMode.icon)
|
||||
}
|
||||
state.addCustomAction(secondaryAction.build())
|
||||
|
||||
// Add the exit action so the service can be closed
|
||||
val exitAction =
|
||||
PlaybackStateCompat.CustomAction.Builder(
|
||||
PlaybackService.ACTION_EXIT,
|
||||
context.getString(R.string.desc_exit),
|
||||
R.drawable.ic_close_24)
|
||||
.build()
|
||||
|
||||
state.addCustomAction(extraAction.build())
|
||||
state.addCustomAction(exitAction)
|
||||
|
||||
mediaSession.setPlaybackState(state.build())
|
||||
}
|
||||
|
||||
/** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
|
||||
private fun invalidateSecondaryAction() {
|
||||
invalidateSessionState()
|
||||
|
||||
|
@ -381,15 +392,28 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
}
|
||||
|
||||
if (!provider.isBusy) {
|
||||
callback.onPostNotification(notification, PostingReason.ACTIONS)
|
||||
callback.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for handling changes in the notification configuration.
|
||||
*/
|
||||
interface Callback {
|
||||
/**
|
||||
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
|
||||
* @param notification The new [NotificationComponent].
|
||||
*/
|
||||
fun onPostNotification(notification: NotificationComponent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* An extended metadata key that stores the resolved name of the [MusicParent] that is
|
||||
* currently being played from.
|
||||
*/
|
||||
const val METADATA_KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT"
|
||||
|
||||
private val emptyMetadata = MediaMetadataCompat.Builder().build()
|
||||
|
||||
private const val ACTIONS =
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
|
|
|
@ -34,9 +34,9 @@ import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
|||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* The unified notification for [PlaybackService]. Due to the nature of how this notification is
|
||||
* used, it is *not self-sufficient*. Updates have to be delivered manually, as to prevent state
|
||||
* inconsistency derived from callback order.
|
||||
* The playback notification component. Due to race conditions regarding notification
|
||||
* updates, this component is not self-sufficient. [MediaSessionComponent] should be used
|
||||
* instead of manage it.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
|
@ -66,7 +66,12 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
|
||||
// --- STATE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Update the currently shown metadata in this notification.
|
||||
* @param metadata The [MediaMetadataCompat] to display in this notification.
|
||||
*/
|
||||
fun updateMetadata(metadata: MediaMetadataCompat) {
|
||||
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
|
||||
setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
|
||||
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
|
||||
|
||||
|
@ -78,21 +83,28 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
} else {
|
||||
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM))
|
||||
}
|
||||
|
||||
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
|
||||
}
|
||||
|
||||
/** Set the playing icon on the notification */
|
||||
/**
|
||||
* Update the playing state shown in this notification.
|
||||
* @param isPlaying Whether playback should be indicated as ongoing or paused.
|
||||
*/
|
||||
fun updatePlaying(isPlaying: Boolean) {
|
||||
mActions[2] = buildPlayPauseAction(context, isPlaying)
|
||||
}
|
||||
|
||||
/** Update the first action to reflect the [repeatMode] given. */
|
||||
/**
|
||||
* Update the secondary action in this notification to show the current [RepeatMode].
|
||||
* @param repeatMode The current [RepeatMode].
|
||||
*/
|
||||
fun updateRepeatMode(repeatMode: RepeatMode) {
|
||||
mActions[0] = buildRepeatAction(context, repeatMode)
|
||||
}
|
||||
|
||||
/** Update the first action to reflect whether the queue is shuffled or not */
|
||||
/**
|
||||
* Update the secondary action in this notification to show the current shuffle state.
|
||||
* @param isShuffled Whether the queue is currently shuffled or not.
|
||||
*/
|
||||
fun updateShuffled(isShuffled: Boolean) {
|
||||
mActions[0] = buildShuffleAction(context, isShuffled)
|
||||
}
|
||||
|
@ -103,8 +115,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
context: Context,
|
||||
isPlaying: Boolean
|
||||
): NotificationCompat.Action {
|
||||
val drawableRes = if (isPlaying) R.drawable.ic_pause_24 else R.drawable.ic_play_24
|
||||
|
||||
val drawableRes = if (isPlaying) {
|
||||
R.drawable.ic_pause_24
|
||||
} else {
|
||||
R.drawable.ic_play_24
|
||||
}
|
||||
return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
|
||||
}
|
||||
|
||||
|
@ -119,9 +134,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
context: Context,
|
||||
isShuffled: Boolean
|
||||
): NotificationCompat.Action {
|
||||
val drawableRes =
|
||||
if (isShuffled) R.drawable.ic_shuffle_on_24 else R.drawable.ic_shuffle_off_24
|
||||
|
||||
val drawableRes = if (isShuffled) {
|
||||
R.drawable.ic_shuffle_on_24
|
||||
} else {
|
||||
R.drawable.ic_shuffle_off_24
|
||||
}
|
||||
return buildAction(context, PlaybackService.ACTION_INVERT_SHUFFLE, drawableRes)
|
||||
}
|
||||
|
||||
|
@ -129,16 +146,13 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
context: Context,
|
||||
actionName: String,
|
||||
@DrawableRes iconRes: Int
|
||||
): NotificationCompat.Action {
|
||||
val action =
|
||||
NotificationCompat.Action.Builder(
|
||||
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
|
||||
|
||||
return action.build()
|
||||
}
|
||||
) =
|
||||
NotificationCompat.Action.Builder(
|
||||
iconRes, actionName, context.newBroadcastPendingIntent(actionName)).build()
|
||||
|
||||
companion object {
|
||||
val CHANNEL_INFO =
|
||||
/** Notification channel used by solely the playback notification. */
|
||||
private val CHANNEL_INFO =
|
||||
ChannelInfo(
|
||||
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
|
||||
nameRes = R.string.lbl_playback)
|
||||
|
|
|
@ -39,8 +39,6 @@ import com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer
|
|||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
|
||||
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
@ -113,8 +111,10 @@ class PlaybackService :
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize the player component.
|
||||
replayGainProcessor = ReplayGainAudioProcessor(this)
|
||||
|
||||
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
|
||||
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
|
@ -129,62 +129,54 @@ class PlaybackService :
|
|||
LibflacAudioRenderer(handler, audioListener, replayGainProcessor))
|
||||
}
|
||||
|
||||
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
|
||||
val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
|
||||
|
||||
player =
|
||||
ExoPlayer.Builder(this, audioRenderer)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory))
|
||||
// Enable automatic WakeLock support
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setAudioAttributes(
|
||||
// Signal that we are a music player.
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
true)
|
||||
.build()
|
||||
|
||||
player.addListener(this)
|
||||
|
||||
.build().also { it.addListener(this) }
|
||||
// Initialize the core service components
|
||||
settings = Settings(this, this)
|
||||
foregroundManager = ForegroundManager(this)
|
||||
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
playbackManager.registerInternalPlayer(this)
|
||||
musicStore.addCallback(this)
|
||||
|
||||
widgetComponent = WidgetComponent(this)
|
||||
mediaSessionComponent = MediaSessionComponent(this, this)
|
||||
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
|
||||
addAction(ACTION_INC_REPEAT_MODE)
|
||||
addAction(ACTION_INVERT_SHUFFLE)
|
||||
addAction(ACTION_SKIP_PREV)
|
||||
addAction(ACTION_PLAY_PAUSE)
|
||||
addAction(ACTION_SKIP_NEXT)
|
||||
addAction(ACTION_EXIT)
|
||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||
|
||||
registerReceiver(systemReceiver, this)
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER SETUP ---
|
||||
registerReceiver(
|
||||
systemReceiver,
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
addAction(ACTION_INC_REPEAT_MODE)
|
||||
addAction(ACTION_INVERT_SHUFFLE)
|
||||
addAction(ACTION_SKIP_PREV)
|
||||
addAction(ACTION_PLAY_PAUSE)
|
||||
addAction(ACTION_SKIP_NEXT)
|
||||
addAction(ACTION_EXIT)
|
||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||
}
|
||||
)
|
||||
|
||||
logD("Service created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
// Forward system media button sent by MediaButtonReciever to MediaSessionComponent
|
||||
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
|
||||
mediaSessionComponent.handleMediaButtonIntent(intent)
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// No binding, service is headless
|
||||
// Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead.
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
// TODO: Implement task removal (Have to radically alter state saving to occur at runtime)
|
||||
|
@ -193,13 +185,13 @@ class PlaybackService :
|
|||
super.onDestroy()
|
||||
|
||||
foregroundManager.release()
|
||||
settings.release()
|
||||
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.changePlaying(false)
|
||||
|
||||
playbackManager.setPlaying(false)
|
||||
playbackManager.unregisterInternalPlayer(this)
|
||||
musicStore.removeCallback(this)
|
||||
settings.release()
|
||||
|
||||
unregisterReceiver(systemReceiver)
|
||||
serviceJob.cancel()
|
||||
|
||||
|
@ -224,13 +216,16 @@ class PlaybackService :
|
|||
override val shouldRewindWithPrev: Boolean
|
||||
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
|
||||
|
||||
override fun makeState(durationMs: Long) =
|
||||
override fun getState(durationMs: Long) =
|
||||
InternalPlayer.State.new(
|
||||
player.playWhenReady, player.isPlaying, max(min(player.currentPosition, durationMs), 0))
|
||||
player.playWhenReady, player.isPlaying,
|
||||
// The position value can be below zero or past the expected duration, make
|
||||
// sure we handle that.
|
||||
player.currentPosition.coerceAtLeast(0).coerceAtMost(durationMs))
|
||||
|
||||
override fun loadSong(song: Song?, play: Boolean) {
|
||||
if (song == null) {
|
||||
// Stop the foreground state if there's nothing to play.
|
||||
// No song, stop playback and foreground state.
|
||||
logD("Nothing playing, stopping playback")
|
||||
player.stop()
|
||||
if (openAudioEffectSession) {
|
||||
|
@ -238,7 +233,6 @@ class PlaybackService :
|
|||
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = false
|
||||
}
|
||||
|
||||
stopAndSave()
|
||||
return
|
||||
}
|
||||
|
@ -263,7 +257,7 @@ class PlaybackService :
|
|||
player.seekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun changePlaying(isPlaying: Boolean) {
|
||||
override fun setPlaying(isPlaying: Boolean) {
|
||||
player.playWhenReady = isPlaying
|
||||
}
|
||||
|
||||
|
@ -271,28 +265,29 @@ class PlaybackService :
|
|||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
|
||||
var needToSynchronize =
|
||||
events.containsAny(Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY)
|
||||
|
||||
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
|
||||
needToSynchronize = true
|
||||
if (player.playWhenReady) {
|
||||
hasPlayed = true
|
||||
}
|
||||
if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) && player.playWhenReady) {
|
||||
// Mark that we have started playing so that the notification can now be posted.
|
||||
hasPlayed = true
|
||||
}
|
||||
|
||||
if (needToSynchronize) {
|
||||
// Any change to the analogous isPlaying, isAdvancing, or positionMs values require
|
||||
// us to synchronize with a new state.
|
||||
if (events.containsAny(
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_IS_PLAYING_CHANGED,
|
||||
Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
playbackManager.synchronizeState(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED) {
|
||||
// Player ended, repeat the current track if we are configured to.
|
||||
if (playbackManager.repeatMode == RepeatMode.TRACK) {
|
||||
playbackManager.rewind()
|
||||
// May be configured to pause when we repeat a track.
|
||||
if (settings.pauseOnRepeat) {
|
||||
playbackManager.changePlaying(false)
|
||||
playbackManager.setPlaying(false)
|
||||
}
|
||||
} else {
|
||||
playbackManager.next()
|
||||
|
@ -308,7 +303,8 @@ class PlaybackService :
|
|||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
super.onTracksChanged(tracks)
|
||||
|
||||
// Try to find the currently playing track so we can update ReplayGainAudioProcessor
|
||||
// with it.
|
||||
for (group in tracks.groups) {
|
||||
if (group.isSelected) {
|
||||
for (i in 0 until group.length) {
|
||||
|
@ -327,6 +323,7 @@ class PlaybackService :
|
|||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library != null) {
|
||||
// We now have a library, see if we have anything we need to do.
|
||||
playbackManager.requestAction(this)
|
||||
}
|
||||
}
|
||||
|
@ -351,11 +348,13 @@ class PlaybackService :
|
|||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
|
||||
}
|
||||
|
||||
/** Stop the foreground state and hide the notification */
|
||||
private fun stopAndSave() {
|
||||
// This session has ended, so we need to reset this flag for when the next session starts.
|
||||
hasPlayed = false
|
||||
|
||||
if (foregroundManager.tryStopForeground()) {
|
||||
// Now that we have ended the foreground state (and thus music playback), we'll need
|
||||
// to save the current state as it's not long until this service (and likely the whole
|
||||
// app) is killed.
|
||||
logD("Saving playback state")
|
||||
saveScope.launch {
|
||||
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
|
||||
|
@ -363,56 +362,54 @@ class PlaybackService :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onAction(action: InternalPlayer.Action): Boolean {
|
||||
override fun performAction(action: InternalPlayer.Action): Boolean {
|
||||
val library = musicStore.library
|
||||
if (library != null) {
|
||||
logD("Performing action: $action")
|
||||
// No library, cannot do anything.
|
||||
?: return false
|
||||
|
||||
when (action) {
|
||||
is InternalPlayer.Action.RestoreState -> {
|
||||
restoreScope.launch {
|
||||
playbackManager.restoreState(
|
||||
PlaybackStateDatabase.getInstance(this@PlaybackService), false)
|
||||
}
|
||||
}
|
||||
is InternalPlayer.Action.ShuffleAll -> {
|
||||
playbackManager.play(null, null, settings, true)
|
||||
}
|
||||
is InternalPlayer.Action.Open -> {
|
||||
library.findSongForUri(application, action.uri)?.let { song ->
|
||||
playbackManager.play(song, null, settings)
|
||||
}
|
||||
logD("Performing action: $action")
|
||||
|
||||
when (action) {
|
||||
// Restore state -> Start a new restoreState job
|
||||
is InternalPlayer.Action.RestoreState -> {
|
||||
restoreScope.launch {
|
||||
playbackManager.restoreState(
|
||||
PlaybackStateDatabase.getInstance(this@PlaybackService), false)
|
||||
}
|
||||
}
|
||||
// Shuffle all -> Start new playback from all songs
|
||||
is InternalPlayer.Action.ShuffleAll -> {
|
||||
playbackManager.play(null, null, settings, true)
|
||||
}
|
||||
// Open -> Try to find the Song for the given file and then play it from all songs
|
||||
is InternalPlayer.Action.Open -> {
|
||||
library.findSongForUri(application, action.uri)?.let { song ->
|
||||
playbackManager.play(song, null, settings)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
// --- MEDIASESSIONCOMPONENT OVERRIDES ---
|
||||
|
||||
override fun onPostNotification(
|
||||
notification: NotificationComponent?,
|
||||
reason: MediaSessionComponent.PostingReason
|
||||
) {
|
||||
if (notification == null) {
|
||||
// This case is only here if I ever need to move foreground stopping from
|
||||
// the player code to the notification code.
|
||||
logD("No notification, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
override fun onPostNotification(notification: NotificationComponent) {
|
||||
// Do not post the notification if playback hasn't started yet. This prevents errors
|
||||
// where changing a setting would cause the notification to appear in an unfriendly
|
||||
// manner.
|
||||
if (hasPlayed) {
|
||||
logD("Updating notification [Reason: $reason]")
|
||||
logD("Updating notification")
|
||||
if (!foregroundManager.tryStartForeground(notification)) {
|
||||
notification.post()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A [BroadcastReceiver] for receiving general playback events from the system. */
|
||||
/**
|
||||
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require
|
||||
* an active [IntentFilter] to be registered.
|
||||
*/
|
||||
private inner class PlaybackReceiver : BroadcastReceiver() {
|
||||
private var initialHeadsetPlugEventHandled = false
|
||||
|
||||
|
@ -425,22 +422,21 @@ class PlaybackService :
|
|||
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
|
||||
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
|
||||
// a non-starter since both require me to display a permission prompt
|
||||
// 3. Some weird internal framework thing that also handles bluetooth headsets???
|
||||
//
|
||||
// They should have just stopped at ACTION_HEADSET_PLUG.
|
||||
// 3. Some internal framework thing that also handles bluetooth headsets
|
||||
// Just use ACTION_HEADSET_PLUG.
|
||||
AudioManager.ACTION_HEADSET_PLUG -> {
|
||||
when (intent.getIntExtra("state", -1)) {
|
||||
0 -> pauseFromPlug()
|
||||
1 -> maybeResumeFromPlug()
|
||||
0 -> pauseFromHeadsetPlug()
|
||||
1 -> playFromHeadsetPlug()
|
||||
}
|
||||
|
||||
initialHeadsetPlugEventHandled = true
|
||||
}
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromHeadsetPlug()
|
||||
|
||||
// --- AUXIO EVENTS ---
|
||||
ACTION_PLAY_PAUSE ->
|
||||
playbackManager.changePlaying(!playbackManager.playerState.isPlaying)
|
||||
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
||||
ACTION_INC_REPEAT_MODE ->
|
||||
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
||||
ACTION_INVERT_SHUFFLE ->
|
||||
|
@ -448,48 +444,40 @@ class PlaybackService :
|
|||
ACTION_SKIP_PREV -> playbackManager.prev()
|
||||
ACTION_SKIP_NEXT -> playbackManager.next()
|
||||
ACTION_EXIT -> {
|
||||
playbackManager.changePlaying(false)
|
||||
playbackManager.setPlaying(false)
|
||||
stopAndSave()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume from a headset plug event in the case that the quirk is enabled. This
|
||||
* functionality remains a quirk for two reasons:
|
||||
* 1. Automatically resuming more or less overrides all other audio streams, which is not
|
||||
* that friendly
|
||||
* 2. There is a bug where playback will always start when this service starts, mostly due
|
||||
* to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear
|
||||
* that it may not work on OEM skins that for whatever reason don't make this action fire.
|
||||
*/
|
||||
private fun maybeResumeFromPlug() {
|
||||
if (playbackManager.song != null &&
|
||||
settings.headsetAutoplay &&
|
||||
private fun playFromHeadsetPlug() {
|
||||
// ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached,
|
||||
// which would result in unexpected playback. Work around it by dropping the first
|
||||
// call to this function, which should come from that Intent.
|
||||
if (settings.headsetAutoplay &&
|
||||
playbackManager.song != null &&
|
||||
initialHeadsetPlugEventHandled) {
|
||||
logD("Device connected, resuming")
|
||||
playbackManager.changePlaying(true)
|
||||
playbackManager.setPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause from a headset plug. */
|
||||
private fun pauseFromPlug() {
|
||||
private fun pauseFromHeadsetPlug() {
|
||||
if (playbackManager.song != null) {
|
||||
logD("Device disconnected, pausing")
|
||||
playbackManager.changePlaying(false)
|
||||
playbackManager.setPlaying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REWIND_THRESHOLD = 3000L
|
||||
|
||||
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
|
||||
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
|
||||
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
|
||||
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
|
||||
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
|
||||
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
|
||||
private const val REWIND_THRESHOLD = 3000L
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.util.getInteger
|
||||
|
||||
/**
|
||||
* A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when it
|
||||
* is activated.
|
||||
* A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when
|
||||
* [isActivated] changes.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AnimatedMaterialButton
|
||||
|
@ -39,15 +39,17 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
override fun setActivated(activated: Boolean) {
|
||||
super.setActivated(activated)
|
||||
|
||||
val target = if (activated) 0.3f else 0.5f
|
||||
// Activated -> Squircle (30% Radius), Inactive -> Circle (50% Radius)
|
||||
val targetRadius = if (activated) 0.3f else 0.5f
|
||||
if (!isLaidOut) {
|
||||
updateCornerRadiusRatio(target)
|
||||
// Not laid out, initialize it without animation before drawing.
|
||||
updateCornerRadiusRatio(targetRadius)
|
||||
return
|
||||
}
|
||||
|
||||
animator?.cancel()
|
||||
animator =
|
||||
ValueAnimator.ofFloat(currentCornerRadiusRatio, target).apply {
|
||||
ValueAnimator.ofFloat(currentCornerRadiusRatio, targetRadius).apply {
|
||||
duration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
addUpdateListener { updateCornerRadiusRatio(animatedValue as Float) }
|
||||
start()
|
||||
|
@ -56,6 +58,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
|
||||
private fun updateCornerRadiusRatio(ratio: Float) {
|
||||
currentCornerRadiusRatio = ratio
|
||||
// Can't reproduce the intrinsic ratio corner radius, just manually implement it with
|
||||
// a dimension value.
|
||||
shapeAppearanceModel = shapeAppearanceModel.withCornerSize { it.width() * ratio }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,16 +23,10 @@ import android.view.View
|
|||
import android.widget.FrameLayout
|
||||
|
||||
/**
|
||||
* A class that programmatically overrides the child layout to a left-to-right (LTR) layout
|
||||
* direction.
|
||||
*
|
||||
* The Material Design guidelines state that any components that represent a "Timeline" should
|
||||
* always be LTR. In Auxio, this applies to most of the playback components. This layout in
|
||||
* particular overrides the layout direction in a way that will not disrupt how other views are laid
|
||||
* out.
|
||||
*
|
||||
* This layout can only contain one child.
|
||||
*
|
||||
* A [FrameLayout] that programmatically overrides the child layout to a left-to-right (LTR) layout
|
||||
* direction. This is useful for "Timeline" elements that Material Design recommends be LTR in all
|
||||
* cases. This layout can only contain one child, to prevent conflicts with other layout
|
||||
* components.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
open class ForcedLTRFrameLayout
|
||||
|
|
|
@ -27,9 +27,8 @@ import org.oxycblt.auxio.util.inflater
|
|||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A wrapper around [Slider] that shows not only position and duration values, but also hacks in
|
||||
* bounds checking to avoid app crashes if bad position input comes in.
|
||||
*
|
||||
* A wrapper around [Slider] that shows position and duration values and sanitizes input to reduce
|
||||
* crashes from invalid values.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class StyledSeekBar
|
||||
|
@ -45,11 +44,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
binding.seekBarSlider.addOnChangeListener(this)
|
||||
}
|
||||
|
||||
var onSeekConfirmed: ((Long) -> Unit)? = null
|
||||
/** The current [Listener] attached to this instance. */
|
||||
var listener: Listener? = null
|
||||
|
||||
/**
|
||||
* The current position, in seconds. This is the current value of the SeekBar and is indicated
|
||||
* by the start TextView in the layout.
|
||||
* The current position, in deci-seconds(1/10th of a second). This is the current value of the
|
||||
* SeekBar and is indicated by the start TextView in the layout.
|
||||
*/
|
||||
var positionDs: Long
|
||||
get() = binding.seekBarSlider.value.toLong()
|
||||
|
@ -57,22 +57,20 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
// Sanity check 1: Ensure that no negative values are sneaking their way into
|
||||
// this component.
|
||||
val from = max(value, 0)
|
||||
|
||||
// Sanity check 2: Ensure that this value is within the duration and will not crash
|
||||
// the app, and that the user is not currently seeking (which would cause the SeekBar
|
||||
// to jump around).
|
||||
if (from <= durationDs && !isActivated) {
|
||||
binding.seekBarSlider.value = from.toFloat()
|
||||
|
||||
// We would want to keep this in the callback, but the callback only fires when
|
||||
// We would want to keep this in the listener, but the listener only fires when
|
||||
// a value changes completely, and sometimes that does not happen with this view.
|
||||
binding.seekBarPosition.text = from.formatDurationDs(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current duration, in seconds. This is the end value of the SeekBar and is indicated by
|
||||
* the end TextView in the layout.
|
||||
* The current duration, in deci-seconds (1/10th of a second). This is the end value of the
|
||||
* SeekBar and is indicated by the end TextView in the layout.
|
||||
*/
|
||||
var durationDs: Long
|
||||
get() = binding.seekBarSlider.valueTo.toLong()
|
||||
|
@ -81,14 +79,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
// zero, use 1 instead and disable the SeekBar.
|
||||
val to = max(value, 1)
|
||||
isEnabled = value > 0
|
||||
|
||||
// Sanity check 2: If the current value exceeds the new duration value, clamp it
|
||||
// down so that we don't crash and instead have an annoying visual flicker.
|
||||
if (positionDs > to) {
|
||||
logD("Clamping invalid position [current: $positionDs new max: $to]")
|
||||
binding.seekBarSlider.value = to.toFloat()
|
||||
}
|
||||
|
||||
binding.seekBarSlider.valueTo = to.toFloat()
|
||||
binding.seekBarDuration.text = value.formatDurationDs(false)
|
||||
}
|
||||
|
@ -102,12 +98,22 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
logD("Confirming seek")
|
||||
// End of seek event, send off new value to callback.
|
||||
// End of seek event, send off new value to listener.
|
||||
isActivated = false
|
||||
onSeekConfirmed?.invoke(slider.value.toLong())
|
||||
listener?.onSeekConfirmed(slider.value.toLong())
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
binding.seekBarPosition.text = value.toLong().formatDurationDs(true)
|
||||
}
|
||||
|
||||
/** A listener for SeekBar interactions. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the internal [Slider] was scrubbed to a new position, requesting that
|
||||
* a seek be performed.
|
||||
* @param positionDs The position to seek to, in deci-seconds (1/10th of a second).
|
||||
*/
|
||||
fun onSeekConfirmed(positionDs: Long)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,10 +70,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
/**
|
||||
* Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from
|
||||
* jumping around.
|
||||
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable. TODO:
|
||||
* Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
|
||||
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
|
||||
*/
|
||||
fun expandWithRecycler(recycler: RecyclerView?) {
|
||||
// TODO: Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
|
||||
setExpanded(true)
|
||||
recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) }
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.ui
|
||||
package org.oxycblt.auxio.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
|
@ -31,7 +31,10 @@ import org.oxycblt.auxio.util.systemGestureInsetsCompat
|
|||
|
||||
/**
|
||||
* A BottomSheetBehavior that resolves several issues with the default implementation, including:
|
||||
* 1.
|
||||
* 1. No reasonable edge-to-edge support.
|
||||
* 2. Strange corner radius behaviors.
|
||||
* 3. Inability to skip half-expanded state when full-screen.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
NeoBottomSheetBehavior<V>(context, attributeSet) {
|
||||
|
@ -73,7 +76,6 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
|
|||
|
||||
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
|
||||
val layout = super.onLayoutChild(parent, child, layoutDirection)
|
||||
|
||||
// Don't repeat redundant initialization.
|
||||
if (!initalized) {
|
||||
child.apply {
|
||||
|
@ -83,14 +85,11 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
|
|||
background = createBackground(context)
|
||||
setOnApplyWindowInsetsListener(::applyWindowInsets)
|
||||
}
|
||||
|
||||
initalized = true
|
||||
}
|
||||
|
||||
// Sometimes CoordinatorLayout doesn't dispatch window insets to us, likely due to how
|
||||
// much we overload it. Ensure that we get them.
|
||||
child.requestApplyInsets()
|
||||
|
||||
return layout
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.ui
|
||||
package org.oxycblt.auxio.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
|
@ -30,20 +30,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
|
||||
/**
|
||||
* A behavior that automatically re-layouts and re-insets content to align with the parent layout's
|
||||
* bottom sheet.
|
||||
*
|
||||
* Ideally, we would one day want to switch to only re-insetting content, however this comes with
|
||||
* several issues::
|
||||
* 1. Scroll position. I need to find a good way to save padding in order to prevent desync, as
|
||||
* window insets tend to be applied after restoration.
|
||||
* 2. Over scrolling. Glow scrolls will not cut it, as the bottom glow will be caught under the bar,
|
||||
* and moving it above the insets will result in an incorrect glow position when the bar is not
|
||||
* shown. I have to emulate stretch scrolling below Android 12 instead. However, this is also
|
||||
* similarly distorted by the insets, and thus I must go further and modify the edge effect to be at
|
||||
* least somewhat clamped to the insets themselves.
|
||||
* 3. Touch events. Bottom sheets must always intercept touches in their bounds, or they will click
|
||||
* the now overlapping content view that is only inset and not moved out of the way..
|
||||
*
|
||||
* bottom sheet. Ideally, we would only want to re-inset content, but that has too many issues to
|
||||
* sensibly implement.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class BottomSheetContentBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
|
@ -62,7 +62,6 @@ class NavigationViewModel : ViewModel() {
|
|||
logD("Already navigating, not doing main action")
|
||||
return
|
||||
}
|
||||
|
||||
logD("Navigating with action $action")
|
||||
_mainNavigationAction.value = action
|
||||
}
|
||||
|
@ -78,14 +77,13 @@ class NavigationViewModel : ViewModel() {
|
|||
|
||||
/**
|
||||
* Navigate to a given [Music] item. Will do nothing if already navigating.
|
||||
* @param item The [Music] to navigate to. TODO: Extend to song properties???
|
||||
* @param item The [Music] to navigate to.
|
||||
*/
|
||||
fun exploreNavigateTo(item: Music) {
|
||||
if (_exploreNavigationItem.value != null) {
|
||||
logD("Already navigating, not doing explore action")
|
||||
return
|
||||
}
|
||||
|
||||
logD("Navigating to ${item.rawName}")
|
||||
_exploreNavigationItem.value = item
|
||||
}
|
||||
|
|
|
@ -86,8 +86,7 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delegate to automatically create and destroy an object derived from the [ViewBinding]. TODO:
|
||||
* Phase this out, it's really dumb
|
||||
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
|
||||
* @param create Block to create the object from the [ViewBinding].
|
||||
*/
|
||||
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
|
||||
|
|
63
app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt
Normal file
63
app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt
Normal 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")
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package org.oxycblt.auxio.util
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
@ -29,6 +30,7 @@ import androidx.activity.viewModels
|
|||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
|
@ -227,16 +229,6 @@ inline fun <reified T : AndroidViewModel> Fragment.androidActivityViewModels() =
|
|||
inline val AndroidViewModel.context: Context
|
||||
get() = getApplication()
|
||||
|
||||
/**
|
||||
* Query all columns in the given [SQLiteDatabase] table, running the block when the [Cursor] is
|
||||
* loaded. The block will be called with [use], allowing for automatic cleanup of [Cursor]
|
||||
* resources.
|
||||
* @param tableName The name of the table to query all columns in.
|
||||
* @param block The code block to run with the loaded [Cursor].
|
||||
*/
|
||||
inline fun <R> SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) =
|
||||
query(tableName, null, null, null, null, null, null)?.use(block)
|
||||
|
||||
/**
|
||||
* Get the "System Bar" [Insets] in this [WindowInsets] instance in a version-compatible manner This
|
||||
* can be used to prevent [View] elements from intersecting with the navigation bars.
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior"
|
||||
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
|
||||
app:navGraph="@navigation/nav_explore"
|
||||
tools:layout="@layout/fragment_home" />
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior"
|
||||
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
|
||||
app:navGraph="@navigation/nav_explore"
|
||||
tools:layout="@layout/fragment_home" />
|
||||
|
||||
|
@ -35,7 +35,7 @@
|
|||
android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior" />
|
||||
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/queue_sheet"
|
||||
|
|
Loading…
Reference in a new issue