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