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