diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f106a3aac..f0aff366e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -23,8 +23,8 @@ jobs: cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Test app with Gradle - run: ./gradlew app:testDebug + # - name: Test app with Gradle + # run: ./gradlew app:testDebug - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact diff --git a/CHANGELOG.md b/CHANGELOG.md index 63626d664..0c2556181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 3.1.1 + +#### What's New +- Added ability to share a track + +#### What's Improved +- Tracks with no disc number now default to "No Disc" instead of "Disc 1" +- Albums implicitly linked only via "artist" tags are now placed in a special +"appears on" section in the artist view +- Album covers that are not 1:1 aspect ratio are no longer cropped +- Optimized library creation phase of the music loading process + +#### What's Fixed +- Prevented options such as "Add to queue" from being selected on empty artists and playlists +- Fixed issue where an item would be indicated as "playing" after playback ended +- Items should no longer be indicated as playing if the currently playing song is not contained +within it +- Fixed blurry playing indicator in album/artist/genre/playlist items +- Fixed incorrect songs being displayed when adding albums to the end of the queue +- Fixed freezing occuring when scrolling through large music libraries +- Fixed app not responding once music loading completes for large libraries +- Fixed crash when the last song of the queue gets removed while playing +- Fixed playback UI and notification not re-appearing after playback ends + +#### What's Changed +- Android Lollipop and Marshmallow support have been dropped + ## 3.1.0 #### What's New diff --git a/README.md b/README.md index fb8ede52d..f5e5ebabe 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases @@ -11,7 +11,7 @@ - Minimum SDK Version + Minimum SDK Version

Changelog | Wiki

diff --git a/app/build.gradle b/app/build.gradle index 4f968216b..cc1075aad 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,10 +20,10 @@ android { defaultConfig { applicationId namespace - versionName "3.1.0" - versionCode 30 + versionName "3.1.1" + versionCode 31 - minSdk 21 + minSdk 24 targetSdk 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -86,13 +86,13 @@ dependencies { // General implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.core:core-ktx:1.10.1" - implementation "androidx.activity:activity-ktx:1.7.1" + implementation "androidx.activity:activity-ktx:1.7.2" implementation "androidx.fragment:fragment-ktx:1.5.7" // UI implementation "androidx.recyclerview:recyclerview:1.3.0" implementation "androidx.constraintlayout:constraintlayout:2.1.4" - implementation "androidx.viewpager2:viewpager2:1.1.0-beta01" + implementation "androidx.viewpager2:viewpager2:1.1.0-beta02" implementation 'androidx.core:core-ktx:1.10.1' // Lifecycle @@ -125,7 +125,7 @@ dependencies { implementation project(":media-lib-decoder-ffmpeg") // Image loading - implementation 'io.coil-kt:coil-base:2.3.0' + implementation 'io.coil-kt:coil-base:2.4.0' // Material // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index a472ce7ab..c6560c151 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -16,8 +16,6 @@ package com.google.android.material.bottomsheet; -import com.google.android.material.R; - import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static java.lang.Math.max; import static java.lang.Math.min; @@ -44,6 +42,7 @@ import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; import android.view.ViewParent; import android.view.accessibility.AccessibilityEvent; + import androidx.annotation.FloatRange; import androidx.annotation.IntDef; import androidx.annotation.NonNull; @@ -63,11 +62,14 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.Accessibilit import androidx.core.view.accessibility.AccessibilityViewCommand; import androidx.customview.view.AbsSavedState; import androidx.customview.widget.ViewDragHelper; + +import com.google.android.material.R; import com.google.android.material.internal.ViewUtils; import com.google.android.material.internal.ViewUtils.RelativePadding; import com.google.android.material.resources.MaterialResources; import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.ShapeAppearanceModel; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.ref.WeakReference; @@ -1334,6 +1336,19 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo return state; } + /** + * Gets the target state of the bottom sheet if currently attempting to settle, or the current + * state otherwise. + * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED}, + * or {@link #STATE_DRAGGING} + */ + public int getTargetState() { + if (state != STATE_SETTLING) { + return state; + } + return stateSettlingTracker.targetState; + } + void setStateInternal(@State int state) { if (this.state == state) { return; diff --git a/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java index 066429513..26de5108b 100644 --- a/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java +++ b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java @@ -16,20 +16,16 @@ package com.google.android.material.divider; -import com.google.android.material.R; - import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.ShapeDrawable; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.ItemDecoration; import android.util.AttributeSet; import android.view.View; import android.widget.LinearLayout; + import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; @@ -39,6 +35,11 @@ import androidx.annotation.Px; import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ItemDecoration; + +import com.google.android.material.R; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.resources.MaterialResources; diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index d29b513a6..725f60444 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -36,6 +36,7 @@ import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.systemBarInsetsCompat /** @@ -50,8 +51,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * TODO: Unit testing * TODO: Fix UID naming * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) - * TODO: Add more logging - * TODO: Try to move on from synchronized and volatile in shared objs + * TODO: Improve multi-threading support in shared objects */ @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -121,6 +121,7 @@ class MainActivity : AppCompatActivity() { private fun startIntentAction(intent: Intent?): Boolean { if (intent == null) { // Nothing to do. + logD("No intent to handle") return false } @@ -129,6 +130,7 @@ class MainActivity : AppCompatActivity() { // This is because onStart can run multiple times, and thus we really don't // want to return false and override the original delayed action with a // RestoreState action. + logD("Already used this intent") return true } intent.putExtra(KEY_INTENT_USED, true) @@ -137,8 +139,12 @@ class MainActivity : AppCompatActivity() { when (intent.action) { Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false) Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll - else -> return false + else -> { + logW("Unexpected intent ${intent.action}") + return false + } } + logD("Translated intent to $action") playbackModel.startAction(action) return true } diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 665fc7bdc..ba09eddf0 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -26,11 +26,13 @@ import androidx.activity.OnBackPressedCallback import androidx.core.view.ViewCompat import androidx.core.view.isInvisible import androidx.core.view.updatePadding +import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController +import com.google.android.material.R as MR import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.transition.MaterialFadeThrough @@ -50,13 +52,24 @@ import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.coordinatorLayoutBehavior +import org.oxycblt.auxio.util.getAttrColorCompat +import org.oxycblt.auxio.util.getDimen +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.systemBarInsetsCompat +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A wrapper around the home fragment that shows the playback fragment and controls the more * high-level navigation features. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Break up the god navigation setup going on here */ @AndroidEntryPoint class MainFragment : @@ -68,7 +81,10 @@ class MainFragment : private val playbackModel: PlaybackViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() - private val callback = DynamicBackPressedCallback() + private var sheetBackCallback: SheetBackPressedCallback? = null + private var detailBackCallback: DetailBackPressedCallback? = null + private var selectionBackCallback: SelectionBackPressedCallback? = null + private var exploreBackCallback: ExploreBackPressedCallback? = null private var lastInsets: WindowInsets? = null private var elevationNormal = 0f private var initialNavDestinationChange = true @@ -84,13 +100,38 @@ class MainFragment : override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) + val playbackSheetBehavior = + binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior + val queueSheetBehavior = + binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? + elevationNormal = binding.context.getDimen(R.dimen.elevation_normal) + // Currently all back press callbacks are handled in MainFragment, as it's not guaranteed + // that instantiating these callbacks in their respective fragments would result in the + // correct order. + val sheetBackCallback = + SheetBackPressedCallback( + playbackSheetBehavior = playbackSheetBehavior, + queueSheetBehavior = queueSheetBehavior) + .also { sheetBackCallback = it } + val detailBackCallback = + DetailBackPressedCallback(detailModel).also { detailBackCallback = it } + val selectionBackCallback = + SelectionBackPressedCallback(selectionModel).also { selectionBackCallback = it } + val exploreBackCallback = + ExploreBackPressedCallback(binding.exploreNavHost).also { exploreBackCallback = it } + // --- UI SETUP --- val context = requireActivity() // 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) + context.onBackPressedDispatcher.apply { + addCallback(viewLifecycleOwner, exploreBackCallback) + addCallback(viewLifecycleOwner, selectionBackCallback) + addCallback(viewLifecycleOwner, detailBackCallback) + addCallback(viewLifecycleOwner, sheetBackCallback) + } binding.root.setOnApplyWindowInsetsListener { _, insets -> lastInsets = insets @@ -103,13 +144,10 @@ class MainFragment : ViewCompat.setAccessibilityPaneTitle( binding.queueSheet, context.getString(R.string.lbl_queue)) - val queueSheetBehavior = - binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? if (queueSheetBehavior != null) { - // Bottom sheet mode, set up click listeners. - val playbackSheetBehavior = - binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - unlikelyToBeNull(binding.handleWrapper).setOnClickListener { + // In portrait mode, set up click listeners on the stacked sheets. + logD("Configuring stacked bottom sheets") + unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener { if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is expanded and queue sheet is collapsed, we can expand it. @@ -118,14 +156,15 @@ class MainFragment : } } else { // Dual-pane mode, manually style the static queue sheet. + logD("Configuring dual-pane bottom sheet") binding.queueSheet.apply { // Emulate the elevated bottom sheet style. background = MaterialShapeDrawable.createWithElevationOverlay(context).apply { - fillColor = context.getAttrColorCompat(R.attr.colorSurface) + fillColor = context.getAttrColorCompat(MR.attr.colorSurface) elevation = context.getDimen(R.dimen.elevation_normal) } - // Apply bar insets for the queue's RecyclerView to usee. + // Apply bar insets for the queue's RecyclerView to use. setOnApplyWindowInsetsListener { v, insets -> v.updatePadding(top = insets.systemBarInsetsCompat.top) insets @@ -134,13 +173,15 @@ class MainFragment : } // --- VIEWMODEL SETUP --- - collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) - collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) - collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) + collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) + collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist) collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) + collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) + collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) + collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -165,6 +206,14 @@ class MainFragment : binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this) } + override fun onDestroyBinding(binding: FragmentMainBinding) { + super.onDestroyBinding(binding) + sheetBackCallback = null + detailBackCallback = null + selectionBackCallback = null + exploreBackCallback = null + } + override fun onPreDraw(): Boolean { // We overload CoordinatorLayout far too much to rely on any of it's typical // listener functionality. Just update all transitions before every draw. Should @@ -250,7 +299,8 @@ class MainFragment : // Since the navigation listener is also reliant on the bottom sheets, we must also update // it every frame. - callback.invalidateEnabled() + requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" } + .invalidateEnabled() return true } @@ -263,6 +313,8 @@ class MainFragment : // Drop the initial call by NavController that simply provides us with the current // destination. This would cause the selection state to be lost every time the device // rotates. + requireNotNull(exploreBackCallback) { "ExploreBackPressedCallback was not available" } + .invalidateEnabled() if (!initialNavDestinationChange) { initialNavDestinationChange = true return @@ -271,19 +323,15 @@ class MainFragment : } private fun handleMainNavigation(action: MainNavigationAction?) { - if (action == null) { - // Nothing to do. - return + if (action != null) { + when (action) { + is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel() + is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel() + is MainNavigationAction.Directions -> + findNavController().navigateSafe(action.directions) + } + navModel.mainNavigationAction.consume() } - - when (action) { - is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel() - is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel() - is MainNavigationAction.Directions -> - findNavController().navigateSafe(action.directions) - } - - navModel.mainNavigationAction.consume() } private fun handleExploreNavigation(item: Music?) { @@ -368,6 +416,7 @@ class MainFragment : if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is not expanded and not hidden, we can expand it. + logD("Expanding playback sheet") playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED return } @@ -378,6 +427,7 @@ class MainFragment : queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { // Queue sheet and playback sheet is expanded, close the queue sheet so the // playback panel can eb shown. + logD("Collapsing queue sheet") queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED } } @@ -388,6 +438,7 @@ class MainFragment : binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { // Playback sheet (and possibly queue) needs to be collapsed. + logD("Collapsing playback and queue sheets") val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED @@ -399,7 +450,8 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) { + logD("Unhiding and enabling playback sheet") val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? // Queue sheet behavior is either collapsed or expanded, no hiding needed @@ -416,10 +468,12 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? + logD("Hiding and disabling playback and queue sheets") + // Make both bottom sheets non-draggable so the user can't halt the hiding event. queueSheetBehavior?.apply { isDraggable = false @@ -433,71 +487,86 @@ class MainFragment : } } - /** - * A [OnBackPressedCallback] that overrides the back button to first navigate out of internal - * app components, such as the Bottom Sheets or Explore Navigation. - */ - private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - val binding = requireBinding() - val playbackSheetBehavior = - binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - val queueSheetBehavior = - binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? + // TODO: Use targetState more + private class SheetBackPressedCallback( + private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>, + private val queueSheetBehavior: QueueBottomSheetBehavior<*>? + ) : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { // If expanded, collapse the queue sheet first. - if (queueSheetBehavior != null && - queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && - playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { - queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED + if (queueSheetShown()) { + unlikelyToBeNull(queueSheetBehavior).state = + BackportBottomSheetBehavior.STATE_COLLAPSED + logD("Collapsed queue sheet") return } // If expanded, collapse the playback sheet next. - if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && - playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetShown()) { playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED + logD("Collapsed playback sheet") return } - - // Clear out pending playlist edits. - if (detailModel.dropPlaylistEdit()) { - return - } - - // Clear out any prior selections. - if (selectionModel.drop()) { - return - } - - // Then try to navigate out of the explore navigation fragments (i.e Detail Views) - binding.exploreNavHost.findNavController().navigateUp() } - /** - * Force this instance to update whether it's enabled or not. If there are no app components - * that the back button should close first, the instance is disabled and back navigation is - * delegated to the system. - * - * 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. - */ fun invalidateEnabled() { - val binding = requireBinding() - val playbackSheetBehavior = - binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - val queueSheetBehavior = - binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? - val exploreNavController = binding.exploreNavHost.findNavController() + isEnabled = queueSheetShown() || playbackSheetShown() + } + private fun playbackSheetShown() = + playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_COLLAPSED && + playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN + + private fun queueSheetShown() = + queueSheetBehavior != null && + playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && + queueSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_COLLAPSED + } + + private class DetailBackPressedCallback(private val detailModel: DetailViewModel) : + OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (detailModel.dropPlaylistEdit()) { + logD("Dropped playlist edits") + } + } + + fun invalidateEnabled(playlistEdit: List?) { + isEnabled = playlistEdit != null + } + } + + private inner class SelectionBackPressedCallback( + private val selectionModel: SelectionViewModel + ) : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (selectionModel.drop()) { + logD("Dropped selection") + } + } + + fun invalidateEnabled(selection: List) { + isEnabled = selection.isNotEmpty() + } + } + + private inner class ExploreBackPressedCallback( + private val exploreNavHost: FragmentContainerView + ) : OnBackPressedCallback(false) { + // Note: We cannot cache the NavController in a variable since it's current destination + // value goes stale for some reason. + + override fun handleOnBackPressed() { + exploreNavHost.findNavController().navigateUp() + logD("Forwarded back navigation to explore nav host") + } + + fun invalidateEnabled() { + val exploreNavController = exploreNavHost.findNavController() isEnabled = - queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED || - playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED || - detailModel.editedPlaylist.value != null || - selectionModel.selected.value.isNotEmpty() || - exploreNavController.currentDestination?.id != - exploreNavController.graph.startDestinationId + exploreNavController.currentDestination?.id != + exploreNavController.graph.startDestinationId } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index d1f13160c..d32f5254b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -51,7 +51,16 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.canScroll +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.share +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A [ListFragment] that shows information about an [Album]. @@ -156,7 +165,14 @@ class AlbumDetailFragment : musicModel.addToPlaylist(currentAlbum) true } - else -> false + R.id.action_share -> { + requireContext().share(currentAlbum) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } @@ -210,7 +226,7 @@ class AlbumDetailFragment : private fun updateAlbum(album: Album?) { if (album == null) { - // Album we were showing no longer exists. + logD("No album to show, navigating away") findNavController().navigateUp() return } @@ -219,12 +235,8 @@ class AlbumDetailFragment : } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { - albumListAdapter.setPlaying(song, isPlaying) - } else { - // Clear the ViewHolders if the mode isn't ALL_SONGS - albumListAdapter.setPlaying(null, isPlaying) - } + albumListAdapter.setPlaying( + song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) } private fun handleNavigation(item: Music?) { @@ -291,7 +303,7 @@ class AlbumDetailFragment : boxStart: Int, boxEnd: Int, snapPreference: Int - ): Int = + ) = (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 4677aee62..601c2ed50 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -49,7 +49,15 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.share +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A [ListFragment] that shows information about an [Artist]. @@ -153,7 +161,14 @@ class ArtistDetailFragment : musicModel.addToPlaylist(currentArtist) true } - else -> false + R.id.action_share -> { + requireContext().share(currentArtist) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } @@ -222,11 +237,23 @@ class ArtistDetailFragment : private fun updateArtist(artist: Artist?) { if (artist == null) { - // Artist we were showing no longer exists. + logD("No artist to show, navigating away") findNavController().navigateUp() return } - requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.apply { + title = artist.name.resolve(requireContext()) + + // Disable options that make no sense with an empty artist + val playable = artist.songs.isNotEmpty() + if (!playable) { + logD("Artist is empty, disabling playback/playlist/share options") + } + menu.findItem(R.id.action_play_next).isEnabled = playable + menu.findItem(R.id.action_queue_add).isEnabled = playable + menu.findItem(R.id.action_playlist_add).isEnabled = playable + menu.findItem(R.id.action_share).isEnabled = playable + } artistHeaderAdapter.setParent(artist) } @@ -234,14 +261,14 @@ class ArtistDetailFragment : val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) val playingItem = when (parent) { - // Always highlight a playing album if it's from this artist. - is Album -> parent + // Always highlight a playing album if it's from this artist, and if the currently + // playing song is contained within. + is Album -> parent.takeIf { song?.album == it } // If the parent is the artist itself, use the currently playing song. currentArtist -> song // Nothing is playing from this artist. else -> null } - artistListAdapter.setPlaying(playingItem, isPlaying) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt index 15b803ae6..28c1f65f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.ui.CoordinatorAppBarLayout import org.oxycblt.auxio.util.getInteger import org.oxycblt.auxio.util.lazyReflectedField +import org.oxycblt.auxio.util.logD /** * An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling @@ -77,7 +78,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr (TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply { // We can never properly initialize the title view's state before draw time, // so we just set it's alpha to 0f to produce a less jarring initialization - // animation.. + // animation. alpha = 0f } @@ -101,12 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr if (titleShown == visible) return titleShown = visible - val titleAnimator = titleAnimator - if (titleAnimator != null) { - titleAnimator.cancel() - this.titleAnimator = null - } - // Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with // the title view's alpha instead of the AppBarLayout's elevation. val titleView = findTitleView() @@ -126,7 +121,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr return } - this.titleAnimator = + logD("Changing title visibility [from: $from to: $to]") + titleAnimator?.cancel() + titleAnimator = ValueAnimator.ofFloat(from, to).apply { addUpdateListener { titleView.alpha = it.animatedValue as Float } duration = 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 92052a955..f18321430 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.list.DiscHeader import org.oxycblt.auxio.detail.list.EditHeader import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.list.BasicHeader @@ -37,12 +38,22 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.Event +import org.oxycblt.auxio.util.MutableEvent +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW /** * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the @@ -60,7 +71,7 @@ constructor( private val playbackSettings: PlaybackSettings ) : ViewModel(), MusicRepository.UpdateListener { // --- SONG --- - + private var currentSongJob: Job? = null private val _currentSong = MutableStateFlow(null) @@ -219,9 +230,9 @@ constructor( if (changes.userLibrary && userLibrary != null) { val playlist = currentPlaylist.value if (playlist != null) { - logD("Updated playlist to ${currentPlaylist.value}") _currentPlaylist.value = userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) + logD("Updated playlist to ${currentPlaylist.value}") } } } @@ -233,8 +244,11 @@ constructor( * @param uid The UID of the [Song] to load. Must be valid. */ fun setSong(uid: Music.UID) { - logD("Opening Song [uid: $uid]") + logD("Opening song $uid") _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo) + if (_currentSong.value == null) { + logW("Given song UID was invalid") + } } /** @@ -244,9 +258,12 @@ constructor( * @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid. */ fun setAlbum(uid: Music.UID) { - logD("Opening Album [uid: $uid]") + logD("Opening album $uid") _currentAlbum.value = musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) + if (_currentAlbum.value == null) { + logW("Given album UID was invalid") + } } /** @@ -256,9 +273,12 @@ constructor( * @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid. */ fun setArtist(uid: Music.UID) { - logD("Opening Artist [uid: $uid]") + logD("Opening artist $uid") _currentArtist.value = musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) + if (_currentArtist.value == null) { + logW("Given artist UID was invalid") + } } /** @@ -268,9 +288,12 @@ constructor( * @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid. */ fun setGenre(uid: Music.UID) { - logD("Opening Genre [uid: $uid]") + logD("Opening genre $uid") _currentGenre.value = musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) + if (_currentGenre.value == null) { + logW("Given genre UID was invalid") + } } /** @@ -280,9 +303,12 @@ constructor( * @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid. */ fun setPlaylist(uid: Music.UID) { - logD("Opening Playlist [uid: $uid]") + logD("Opening playlist $uid") _currentPlaylist.value = musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) + if (_currentPlaylist.value == null) { + logW("Given playlist UID was invalid") + } } /** Start a playlist editing session. Does nothing if a playlist is not being shown. */ @@ -300,6 +326,7 @@ constructor( fun savePlaylistEdit() { val playlist = _currentPlaylist.value ?: return val editedPlaylist = _editedPlaylist.value ?: return + logD("Committing playlist edits") viewModelScope.launch { musicRepository.rewritePlaylist(playlist, editedPlaylist) // TODO: The user could probably press some kind of button if they were fast enough. @@ -320,6 +347,7 @@ constructor( // Nothing to do. return false } + logD("Discarding playlist edits") _editedPlaylist.value = null refreshPlaylistList(playlist) return true @@ -341,6 +369,7 @@ constructor( if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) { return false } + logD("Moving playlist song from $realFrom [$from] to $realTo [$to]") editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) _editedPlaylist.value = editedPlaylist refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) @@ -359,6 +388,7 @@ constructor( if (realAt !in editedPlaylist.indices) { return } + logD("Removing playlist song at $realAt [$at]") editedPlaylist.removeAt(realAt) _editedPlaylist.value = editedPlaylist refreshPlaylistList( @@ -366,11 +396,13 @@ constructor( if (editedPlaylist.isNotEmpty()) { UpdateInstructions.Remove(at, 1) } else { + logD("Playlist will be empty after removal, removing header") UpdateInstructions.Remove(at - 2, 3) }) } private fun refreshAudioInfo(song: Song) { + logD("Refreshing audio info") // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() _songAudioProperties.value = null @@ -378,6 +410,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { val info = audioPropertiesFactory.extract(song) yield() + logD("Updating audio info to $info") _songAudioProperties.value = info } } @@ -399,12 +432,11 @@ constructor( // To create a good user experience regarding disc numbers, we group the album's // songs up by disc and then delimit the groups by a disc header. val songs = albumSongSort.songs(album.songs) - // Songs without disc tags become part of Disc 1. - val byDisc = songs.groupBy { it.disc ?: Disc(1, null) } + val byDisc = songs.groupBy { it.disc } if (byDisc.size > 1) { logD("Album has more than one disc, interspersing headers") for (entry in byDisc.entries) { - list.add(entry.key) + list.add(DiscHeader(entry.key)) list.addAll(entry.value) } } else { @@ -412,6 +444,7 @@ constructor( list.addAll(songs) } + logD("Update album list to ${list.size} items with $instructions") _albumInstructions.put(instructions) _albumList.value = list } @@ -419,10 +452,9 @@ constructor( private fun refreshArtistList(artist: Artist, replace: Boolean = false) { logD("Refreshing artist list") val list = mutableListOf() - val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums) - val byReleaseGroup = - albums.groupBy { + val grouping = + artist.explicitAlbums.groupByTo(sortedMapOf()) { // Remap the complicated ReleaseType data structure into an easier // "AlbumGrouping" enum that will automatically group and sort // the artist's albums. @@ -436,15 +468,25 @@ constructor( is ReleaseType.Single -> AlbumGrouping.SINGLES is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS - is ReleaseType.Mix -> AlbumGrouping.MIXES + is ReleaseType.Mix -> AlbumGrouping.DJMIXES is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES } } } - logD("Release groups for this artist: ${byReleaseGroup.keys}") + if (artist.implicitAlbums.isNotEmpty()) { + // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList + // inherits list, we can cast upwards and save a copy by directly inserting the + // implicit album list into the mapping. + logD("Implicit albums present, adding to list") + @Suppress("UNCHECKED_CAST") + (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] = + artist.implicitAlbums + } - for (entry in byReleaseGroup.entries.sortedBy { it.key }) { + logD("Release groups for this artist: ${grouping.keys}") + + for (entry in grouping.entries) { val header = BasicHeader(entry.key.headerTitleRes) list.add(Divider(header)) list.add(header) @@ -465,6 +507,7 @@ constructor( list.addAll(artistSongSort.songs(artist.songs)) } + logD("Updating artist list to ${list.size} items with $instructions") _artistInstructions.put(instructions) _artistList.value = list.toList() } @@ -483,12 +526,14 @@ constructor( list.add(songHeader) val instructions = if (replace) { - // Intentional so that the header item isn't replaced with the songs + // Intentional so that the header item isn't replaced alongside the songs UpdateInstructions.Replace(list.size) } else { UpdateInstructions.Diff } list.addAll(genreSongSort.songs(genre.songs)) + + logD("Updating genre list to ${list.size} items with $instructions") _genreInstructions.put(instructions) _genreList.value = list } @@ -508,6 +553,7 @@ constructor( list.addAll(songs) } + logD("Updating playlist list to ${list.size} items with $instructions") _playlistInstructions.put(instructions) _playlistList.value = list } @@ -524,8 +570,9 @@ constructor( SINGLES(R.string.lbl_singles), COMPILATIONS(R.string.lbl_compilations), SOUNDTRACKS(R.string.lbl_soundtracks), - MIXES(R.string.lbl_mixes), + DJMIXES(R.string.lbl_mixes), MIXTAPES(R.string.lbl_mixtapes), + APPEARANCES(R.string.lbl_appears_on), LIVE(R.string.lbl_live_group), REMIXES(R.string.lbl_remix_group), } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 4ef67d581..3968c1379 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -41,10 +41,24 @@ import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.share +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A [ListFragment] that shows information for a particular [Genre]. @@ -146,7 +160,14 @@ class GenreDetailFragment : musicModel.addToPlaylist(currentGenre) true } - else -> false + R.id.action_share -> { + requireContext().share(currentGenre) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } @@ -213,7 +234,7 @@ class GenreDetailFragment : private fun updatePlaylist(genre: Genre?) { if (genre == null) { - // Genre we were showing no longer exists. + logD("No genre to show, navigating away") findNavController().navigateUp() return } @@ -222,15 +243,18 @@ class GenreDetailFragment : } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - var playingMusic: Music? = null - if (parent is Artist) { - playingMusic = parent - } - // Prefer songs that might be playing from this genre. - if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) { - playingMusic = song - } - genreListAdapter.setPlaying(playingMusic, isPlaying) + val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) + val playingItem = + when (parent) { + // Always highlight a playing artist if it's from this genre, and if the currently + // playing song is contained within. + is Artist -> parent.takeIf { song?.run { artists.contains(it) } ?: false } + // If the parent is the artist itself, use the currently playing song. + currentGenre -> song + // Nothing is playing from this artist. + else -> null + } + genreListAdapter.setPlaying(playingItem, isPlaying) } private fun handleNavigation(item: Music?) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index c7cf92cde..7cdf9443c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -44,10 +44,24 @@ import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.share +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A [ListFragment] that shows information for a particular [Playlist]. @@ -197,11 +211,18 @@ class PlaylistDetailFragment : musicModel.deletePlaylist(currentPlaylist) true } + R.id.action_share -> { + requireContext().share(currentPlaylist) + true + } R.id.action_save -> { detailModel.savePlaylistEdit() true } - else -> false + else -> { + logW("Unexpected menu item selected") + false + } } } @@ -238,19 +259,26 @@ class PlaylistDetailFragment : return } val binding = requireBinding() - binding.detailNormalToolbar.title = playlist.name.resolve(requireContext()) - binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}" + binding.detailNormalToolbar.apply { + title = playlist.name.resolve(requireContext()) + // Disable options that make no sense with an empty playlist + val playable = playlist.songs.isNotEmpty() + if (!playable) { + logD("Playlist is empty, disabling playback/share options") + } + menu.findItem(R.id.action_play_next).isEnabled = playable + menu.findItem(R.id.action_queue_add).isEnabled = playable + menu.findItem(R.id.action_share).isEnabled = playable + } + binding.detailEditToolbar.title = + getString(R.string.fmt_editing, playlist.name.resolve(requireContext())) playlistHeaderAdapter.setParent(playlist) } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - // Prefer songs that might be playing from this playlist. - if (parent is Playlist && - parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) { - playlistListAdapter.setPlaying(song, isPlaying) - } else { - playlistListAdapter.setPlaying(null, isPlaying) - } + // Prefer songs that are playing from this playlist. + playlistListAdapter.setPlaying( + song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying) } private fun handleNavigation(item: Music?) { @@ -287,6 +315,7 @@ class PlaylistDetailFragment : selectionModel.drop() if (editedPlaylist != null) { + logD("Updating save button state") requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply { isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs } @@ -308,9 +337,18 @@ class PlaylistDetailFragment : private fun updateMultiToolbar() { val id = when { - detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar - selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar - else -> R.id.detail_normal_toolbar + detailModel.editedPlaylist.value != null -> { + logD("Currently editing playlist, showing edit toolbar") + R.id.detail_edit_toolbar + } + selectionModel.selected.value.isNotEmpty() -> { + logD("Currently selecting, showing selection toolbar") + R.id.detail_selection_toolbar + } + else -> { + logD("Using normal toolbar") + R.id.detail_normal_toolbar + } } requireBinding().detailToolbar.setVisible(id) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt index f03ad5c31..716d70c60 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -22,8 +22,8 @@ import android.content.Context import android.os.Build import android.util.AttributeSet import android.view.View +import androidx.appcompat.R import com.google.android.material.textfield.TextInputEditText -import org.oxycblt.auxio.R /** * A [TextInputEditText] that deliberately restricts all input except for selection. This will work diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index 5ba78ea8f..ca38c061e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -41,6 +41,7 @@ import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.concatLocalized +import org.oxycblt.auxio.util.logD /** * A [ViewBindingDialogFragment] that shows information about a Song. @@ -73,7 +74,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { private fun updateSong(song: Song?, info: AudioProperties?) { if (song == null) { - // Song we were showing no longer exists. + logD("No song to show, navigating away") findNavController().navigateUp() return } @@ -86,7 +87,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { add(SongProperty(R.string.lbl_album, song.album.zipName(context))) add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context))) add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context))) - song.date?.let { add(SongProperty(R.string.lbl_date, it.resolveDate(context))) } + song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) } song.track?.let { add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it))) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt index 3b346975e..02303a566 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * A [DetailHeaderAdapter] that shows [Artist] information. @@ -91,6 +92,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : // The artist does not have any songs, so hide functionality that makes no sense. // ex. Play and Shuffle, Song Counts, and Genre Information. // Artists are always guaranteed to have albums however, so continue to show those. + logD("Artist is empty, disabling genres and playback") binding.detailSubhead.isVisible = false binding.detailPlayButton.isEnabled = false binding.detailShuffleButton.isEnabled = false diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt index 06317f5e2..247875432 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.detail.header import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.util.logD /** * A [RecyclerView.Adapter] that implements shared behavior between each parent header view. @@ -47,6 +48,7 @@ abstract class DetailHeaderAdapter?, listener: DetailHeaderAdapter.Listener ) { - // TODO: Debug perpetually re-binding images - binding.detailCover.bind(playlist, editedPlaylist) + if (editedPlaylist != null) { + logD("Binding edited playlist image") + binding.detailCover.bind( + editedPlaylist, + binding.context.getString(R.string.desc_playlist_image, playlist.name), + R.drawable.ic_playlist_24) + } else { + binding.detailCover.bind(playlist) + } + binding.detailType.text = binding.context.getString(R.string.lbl_playlist) binding.detailName.text = playlist.name.resolve(binding.context) // Nothing about a playlist is applicable to the sub-head text. @@ -103,12 +113,17 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.context.getString(R.string.def_song_count) } + val playable = playlist.songs.isNotEmpty() && editedPlaylist == null + if (!playable) { + logD("Playlist is being edited or is empty, disabling playback options") + } + binding.detailPlayButton.apply { - isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null + isEnabled = playable setOnClickListener { listener.onPlay() } } binding.detailShuffleButton.apply { - isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null + isEnabled = playable setOnClickListener { listener.onShuffle() } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index b7217a681..66fc29d7c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -22,6 +22,7 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -37,6 +38,7 @@ import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view. @@ -49,14 +51,14 @@ class AlbumDetailListAdapter(private val listener: Listener) : override fun getItemViewType(position: Int) = when (getItem(position)) { // Support sub-headers for each disc, and special album songs. - is Disc -> DiscViewHolder.VIEW_TYPE + is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { - DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent) + DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) else -> super.onCreateViewHolder(parent, viewType) } @@ -64,7 +66,7 @@ class AlbumDetailListAdapter(private val listener: Listener) : override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) when (val item = getItem(position)) { - is Disc -> (holder as DiscViewHolder).bind(item) + is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) is Song -> (holder as AlbumSongViewHolder).bind(item, listener) } } @@ -76,7 +78,7 @@ class AlbumDetailListAdapter(private val listener: Listener) : override fun areContentsTheSame(oldItem: Item, newItem: Item) = when { oldItem is Disc && newItem is Disc -> - DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) @@ -88,23 +90,37 @@ class AlbumDetailListAdapter(private val listener: Listener) : } /** - * A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from] - * to create an instance. + * A wrapper around [Disc] signifying that a header should be shown for a disc group. * * @author Alexander Capehart (OxygenCobalt) */ -private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) : +data class DiscHeader(val inner: Disc?) : Item + +/** + * A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use + * [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : RecyclerView.ViewHolder(binding.root) { /** * Bind new data to this instance. * - * @param disc The new [disc] to bind. + * @param discHeader The new [DiscHeader] to bind. */ - fun bind(disc: Disc) { - binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) - binding.discName.apply { - text = disc.name - isGone = disc.name == null + fun bind(discHeader: DiscHeader) { + val disc = discHeader.inner + if (disc != null) { + binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) + binding.discName.apply { + text = disc.name + isGone = text == null + } + } else { + logD("Disc is null, defaulting to no disc") + binding.discNumber.text = binding.context.getString(R.string.def_disc) + binding.discName.isGone = true } } @@ -119,7 +135,7 @@ private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) : * @return A new instance. */ fun from(parent: View) = - DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) + DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = @@ -147,31 +163,33 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) - binding.songTrack.apply { - if (song.track != null) { - // Instead of an album cover, we show the track number, as the song list - // within the album detail view would have homogeneous album covers otherwise. + val track = song.track + if (track != null) { + binding.songTrackCover.contentDescription = + binding.context.getString(R.string.desc_track_number, track) + binding.songTrackText.apply { + isVisible = true text = context.getString(R.string.fmt_number, song.track) - isInvisible = false - contentDescription = context.getString(R.string.desc_track_number, song.track) - } else { - // No track, do not show a number, instead showing a generic icon. - text = "" - isInvisible = true - contentDescription = context.getString(R.string.def_track) } + binding.songTrackPlaceholder.isInvisible = true + } else { + binding.songTrackCover.contentDescription = + binding.context.getString(R.string.def_track) + binding.songTrackText.apply { + isInvisible = true + text = null + } + binding.songTrackPlaceholder.isVisible = true } binding.songName.text = song.name.resolve(binding.context) - - // Use duration instead of album or artist for each song, as this text would - // be homogenous otherwise. + // Use duration instead of album or artist for each song to be more contextually relevant. binding.songDuration.text = song.durationMs.formatDurationMs(false) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.songTrackBg.isPlaying = isPlaying + binding.songTrackCover.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt index e281d9982..524c27792 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt @@ -29,7 +29,10 @@ import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater @@ -107,7 +110,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -159,7 +162,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt index 9c43dc875..ba350e7b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt @@ -31,8 +31,10 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.adapter.* -import org.oxycblt.auxio.list.recycler.* +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder +import org.oxycblt.auxio.list.recycler.DividerViewHolder import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index 7b4147621..06c5be29b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -27,6 +27,7 @@ import androidx.appcompat.widget.TooltipCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R as MR import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -47,6 +48,7 @@ import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist] @@ -97,6 +99,7 @@ class PlaylistDetailListAdapter(private val listener: Listener) : // Nothing to do. return } + logD("Updating editing state [old: $isEditing new: $editing]") this.isEditing = editing notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED) } @@ -213,7 +216,7 @@ private constructor(private val binding: ItemEditableSongBinding) : override val delete = binding.background override val background = MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { - fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) + fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface) elevation = binding.context.getDimen(R.dimen.elevation_normal) alpha = 0 } @@ -223,7 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) : LayerDrawable( arrayOf( MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply { - fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) + fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface) }, background)) } @@ -253,7 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) : override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.interactBody.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } override fun updateEditing(editing: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/SongPropertyAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/SongPropertyAdapter.kt index 690f3a792..29f4bf2d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/SongPropertyAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/SongPropertyAdapter.kt @@ -24,7 +24,8 @@ import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemSongPropertyBinding import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.adapter.* +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt index 2b0cd3d5e..a03adccfd 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt @@ -22,8 +22,8 @@ import android.content.Context import android.util.AttributeSet import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import com.google.android.material.R import com.google.android.material.floatingactionbutton.FloatingActionButton -import org.oxycblt.auxio.R import org.oxycblt.auxio.util.logD /** @@ -44,23 +44,32 @@ constructor( override fun show() { // Will already show eventually, need to do nothing. - if (flipping) return + if (flipping) { + logD("Already flipping, aborting show") + return + } // Apply the new configuration possibly set in flipTo. This should occur even if // a flip was canceled by a hide. pendingConfig?.run { + this@FlipFloatingActionButton.logD("Applying pending configuration") setImageResource(iconRes) contentDescription = context.getString(contentDescriptionRes) setOnClickListener(clickListener) } pendingConfig = null + logD("Beginning show") super.show() } override fun hide() { + if (flipping) { + logD("Hide was called, aborting flip") + } // Not flipping anymore, disable the flag so that the FAB is not re-shown. flipping = false // Don't pass any kind of listener so that future flip operations will not be able // to show the FAB again. + logD("Beginning hide") super.hide() } @@ -82,9 +91,12 @@ constructor( // Already hiding for whatever reason, apply the configuration when the FAB is shown again. if (!isOrWillBeHidden) { + logD("Starting hide for flip") flipping = true // We will re-show the FAB later, assuming that there was not a prior flip operation. super.hide(FlipVisibilityListener()) + } else { + logD("Already hiding, will apply config later") } } @@ -97,7 +109,7 @@ constructor( private inner class FlipVisibilityListener : OnVisibilityChangedListener() { override fun onHidden(fab: FloatingActionButton) { if (!flipping) return - logD("Showing for a flip operation") + logD("Starting show for flip") flipping = false show() } 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 5f26f32d5..f1f856684 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -46,16 +46,39 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeBinding -import org.oxycblt.auxio.home.list.* +import org.oxycblt.auxio.home.list.AlbumListFragment +import org.oxycblt.auxio.home.list.ArtistListFragment +import org.oxycblt.auxio.home.list.GenreListFragment +import org.oxycblt.auxio.home.list.PlaylistListFragment +import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.IndexingProgress +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.NoAudioPermissionException +import org.oxycblt.auxio.music.NoMusicException +import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.getColorCompat +import org.oxycblt.auxio.util.lazyReflectedField +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.unlikelyToBeNull /** * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation @@ -188,54 +211,65 @@ class HomeFragment : return true } - when (item.itemId) { + return when (item.itemId) { // Handle main actions (Search, Settings, About) R.id.action_search -> { logD("Navigating to search") setupAxisTransitions(MaterialSharedAxis.Z) findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch()) + true } R.id.action_settings -> { logD("Navigating to settings") navModel.mainNavigateTo( MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings())) + true } R.id.action_about -> { logD("Navigating to about") navModel.mainNavigateTo( MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout())) + true } // Handle sort menu R.id.submenu_sorting -> { // Junk click event when opening the menu + true } R.id.option_sort_asc -> { + logD("Switching to ascending sorting") item.isChecked = true homeModel.setSortForCurrentTab( homeModel .getSortForTab(homeModel.currentTabMode.value) .withDirection(Sort.Direction.ASCENDING)) + true } R.id.option_sort_dec -> { + logD("Switching to descending sorting") item.isChecked = true homeModel.setSortForCurrentTab( homeModel .getSortForTab(homeModel.currentTabMode.value) .withDirection(Sort.Direction.DESCENDING)) + true } else -> { - // Sorting option was selected, mark it as selected and update the mode - item.isChecked = true - homeModel.setSortForCurrentTab( - homeModel - .getSortForTab(homeModel.currentTabMode.value) - .withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId)))) + val newMode = Sort.Mode.fromItemId(item.itemId) + if (newMode != null) { + // Sorting option was selected, mark it as selected and update the mode + logD("Updating sort mode") + item.isChecked = true + homeModel.setSortForCurrentTab( + homeModel.getSortForTab(homeModel.currentTabMode.value).withMode(newMode)) + true + } else { + logW("Unexpected menu item selected") + false + } } } - - // Always handling it one way or another, so always return true - return true } private fun setupPager(binding: FragmentHomeBinding) { @@ -246,6 +280,7 @@ class HomeFragment : if (homeModel.currentTabModes.size == 1) { // A single tab makes the tab layout redundant, hide it and disable the collapsing // behavior. + logD("Single tab shown, disabling TabLayout") binding.homeTabs.isVisible = false binding.homeAppbar.setExpanded(true, false) toolbarParams.scrollFlags = 0 @@ -270,17 +305,26 @@ class HomeFragment : val isVisible: (Int) -> Boolean = when (tabMode) { // Disallow sorting by count for songs - MusicMode.SONGS -> { id -> id != R.id.option_sort_count } + MusicMode.SONGS -> { + logD("Using song-specific menu options") + ({ id -> id != R.id.option_sort_count }) + } // Disallow sorting by album for albums - MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album } + MusicMode.ALBUMS -> { + logD("Using album-specific menu options") + ({ id -> id != R.id.option_sort_album }) + } // Only allow sorting by name, count, and duration for parents - else -> { id -> + else -> { + logD("Using parent-specific menu options") + ({ id -> id == R.id.option_sort_asc || id == R.id.option_sort_dec || id == R.id.option_sort_name || id == R.id.option_sort_count || id == R.id.option_sort_duration - } + }) + } } val sortMenu = @@ -288,18 +332,29 @@ class HomeFragment : val toHighlight = homeModel.getSortForTab(tabMode) for (option in sortMenu) { - // Check the ascending option and corresponding sort option to align with + val isCurrentMode = option.itemId == toHighlight.mode.itemId + val isCurrentlyAscending = + option.itemId == R.id.option_sort_asc && + toHighlight.direction == Sort.Direction.ASCENDING + val isCurrentlyDescending = + option.itemId == R.id.option_sort_dec && + toHighlight.direction == Sort.Direction.DESCENDING + // Check the corresponding direction and mode sort options to align with // the current sort of the tab. - if (option.itemId == toHighlight.mode.itemId || - (option.itemId == R.id.option_sort_asc && - toHighlight.direction == Sort.Direction.ASCENDING) || - (option.itemId == R.id.option_sort_dec && - toHighlight.direction == Sort.Direction.DESCENDING)) { + if (isCurrentMode || isCurrentlyAscending || isCurrentlyDescending) { + logD( + "Checking $option option [mode: $isCurrentMode asc: $isCurrentlyAscending dec: $isCurrentlyDescending]") + // Note: We cannot inline this boolean assignment since it unchecks all other radio + // buttons (even when setting it to false), which would result in nothing being + // selected. option.isChecked = true } // Disable options that are not allowed by the isVisible lambda option.isVisible = isVisible(option.itemId) + if (!option.isVisible) { + logD("Hiding $option option") + } } // Update the scrolling view in AppBarLayout to align with the current tab's @@ -315,10 +370,12 @@ class HomeFragment : } if (tabMode != MusicMode.PLAYLISTS) { + logD("Flipping to shuffle button") binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) { playbackModel.shuffleAll() } } else { + logD("Flipping to playlist button") binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) { musicModel.createPlaylist() } @@ -328,6 +385,7 @@ class HomeFragment : private fun handleRecreate(recreate: Unit?) { if (recreate == null) return val binding = requireBinding() + logD("Recreating ViewPager") // Move back to position zero, as there must be a tab there. binding.homePager.currentItem = 0 // Make sure tabs are set up to also follow the new ViewPager configuration. @@ -364,7 +422,7 @@ class HomeFragment : binding.homeIndexingProgress.visibility = View.INVISIBLE when (error) { is NoAudioPermissionException -> { - logD("Updating UI to permission request state") + logD("Showing permission prompt") binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) // Configure the action to act as a permission launcher. binding.homeIndexingAction.apply { @@ -379,7 +437,7 @@ class HomeFragment : } } is NoMusicException -> { - logD("Updating UI to no music state") + logD("Showing no music error") binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) // Configure the action to act as a reload trigger. binding.homeIndexingAction.apply { @@ -389,7 +447,7 @@ class HomeFragment : } } else -> { - logD("Updating UI to error state") + logD("Showing generic error") binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) // Configure the action to act as a reload trigger. binding.homeIndexingAction.apply { @@ -432,8 +490,10 @@ class HomeFragment : // displaying the shuffle FAB makes no sense. We also don't want the fast scroll // popup to overlap with the FAB, so we hide the FAB when fast scrolling too. if (songs.isEmpty() || isFastScrolling) { + logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]") binding.homeFab.hide() } else { + logD("Showing fab") binding.homeFab.show() } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index 60d3144e7..4e468ec95 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -67,15 +68,18 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) override fun migrate() { if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) { + logD("Migrating tab setting") val oldTabs = Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT)) ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) + logD("Old tabs: $oldTabs") // The playlist tab is now parsed, but it needs to be made visible. val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS } - if (playlistIndex > -1) { // Sanity check - oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS) - } + check(playlistIndex > -1) // This should exist, otherwise we are in big trouble + oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS) + logD("New tabs: $oldTabs") + sharedPreferences.edit { putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs)) remove(OLD_KEY_LIB_TABS) @@ -85,8 +89,14 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) override fun onSettingChanged(key: String, listener: HomeSettings.Listener) { when (key) { - getString(R.string.set_key_home_tabs) -> listener.onTabsChanged() - getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged() + getString(R.string.set_key_home_tabs) -> { + logD("Dispatching tab setting change") + listener.onTabsChanged() + } + getString(R.string.set_key_hide_collaborators) -> { + logD("Dispatching collaborator setting change") + listener.onHideCollaboratorsChanged() + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 8b4e6d581..4e471758a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -26,7 +26,14 @@ import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -68,8 +75,7 @@ constructor( private val _artistsList = MutableStateFlow(listOf()) /** * A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that - * if "Hide collaborators" is on, this list will not include [Artist]s where - * [Artist.isCollaborator] is true. + * if "Hide collaborators" is on, this list will not include collaborator [Artist]s. */ val artistsList: MutableStateFlow> get() = _artistsList @@ -137,7 +143,6 @@ constructor( override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary - logD(changes.deviceLibrary) if (changes.deviceLibrary && deviceLibrary != null) { logD("Refreshing library") // Get the each list of items in the library to use as our list data. @@ -150,9 +155,11 @@ constructor( _artistsList.value = musicSettings.artistSort.artists( if (homeSettings.shouldHideCollaborators) { + logD("Filtering collaborator artists") // Hide Collaborators is enabled, filter out collaborators. - deviceLibrary.artists.filter { !it.isCollaborator } + deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() } } else { + logD("Using all artists") deviceLibrary.artists }) _genresInstructions.put(UpdateInstructions.Diff) @@ -170,12 +177,14 @@ constructor( override fun onTabsChanged() { // Tabs changed, update the current tabs and set up a re-create event. currentTabModes = makeTabModes() + logD("Updating tabs: ${currentTabMode.value}") _shouldRecreate.put(Unit) } override fun onHideCollaboratorsChanged() { // Changes in the hide collaborator setting will change the artist contents // of the library, consider it a library update. + logD("Collaborator setting changed, forwarding update") onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) } @@ -200,30 +209,34 @@ constructor( * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab]. */ fun setSortForCurrentTab(sort: Sort) { - logD("Updating ${_currentTabMode.value} sort to $sort") // Can simply re-sort the current list of items without having to access the library. - when (_currentTabMode.value) { + when (val mode = _currentTabMode.value) { MusicMode.SONGS -> { + logD("Updating song [$mode] sort mode to $sort") musicSettings.songSort = sort _songsInstructions.put(UpdateInstructions.Replace(0)) _songsList.value = sort.songs(_songsList.value) } MusicMode.ALBUMS -> { + logD("Updating album [$mode] sort mode to $sort") musicSettings.albumSort = sort _albumsInstructions.put(UpdateInstructions.Replace(0)) _albumsLists.value = sort.albums(_albumsLists.value) } MusicMode.ARTISTS -> { + logD("Updating artist [$mode] sort mode to $sort") musicSettings.artistSort = sort _artistsInstructions.put(UpdateInstructions.Replace(0)) _artistsList.value = sort.artists(_artistsList.value) } MusicMode.GENRES -> { + logD("Updating genre [$mode] sort mode to $sort") musicSettings.genreSort = sort _genresInstructions.put(UpdateInstructions.Replace(0)) _genresList.value = sort.genres(_genresList.value) } MusicMode.PLAYLISTS -> { + logD("Updating playlist [$mode] sort mode to $sort") musicSettings.playlistSort = sort _playlistsInstructions.put(UpdateInstructions.Replace(0)) _playlistsList.value = sort.playlists(_playlistsList.value) diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt index 3a848edf9..620ac018f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt @@ -33,6 +33,7 @@ import android.text.TextUtils import android.util.AttributeSet import android.view.Gravity import androidx.core.widget.TextViewCompat +import com.google.android.material.R as MR import com.google.android.material.textview.MaterialTextView import org.oxycblt.auxio.R import org.oxycblt.auxio.util.getAttrColorCompat @@ -53,7 +54,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height) TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge) - setTextColor(context.getAttrColorCompat(R.attr.colorOnSecondary)) + setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary)) ellipsize = TextUtils.TruncateAt.MIDDLE gravity = Gravity.CENTER includeFontPadding = false @@ -67,7 +68,10 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) private val paint: Paint = Paint().apply { isAntiAlias = true - color = context.getAttrColorCompat(R.attr.colorSecondary).defaultColor + color = + context + .getAttrColorCompat(com.google.android.material.R.attr.colorSecondary) + .defaultColor style = Paint.Style.FILL } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index 1d0cc6737..991fc6f4e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -37,7 +37,12 @@ import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.list.recycler.AuxioRecyclerView -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.getDimenPixels +import org.oxycblt.auxio.util.getDrawableCompat +import org.oxycblt.auxio.util.getInteger +import org.oxycblt.auxio.util.isRtl +import org.oxycblt.auxio.util.isUnder +import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index a17172d08..3495bc85a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -30,13 +30,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView -import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs @@ -78,7 +83,8 @@ class AlbumListFragment : collectImmediately(homeModel.albumsList, ::updateAlbums) collectImmediately(selectionModel.selected, ::updateSelection) - collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collectImmediately( + playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { @@ -101,7 +107,7 @@ class AlbumListFragment : is Sort.Mode.ByArtist -> album.artists[0].name.thumb // Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd) - is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) } + is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) } // Duration -> Use formatted duration is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false) @@ -147,9 +153,11 @@ class AlbumListFragment : albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) } - private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - // If an album is playing, highlight it within this adapter. - albumAdapter.setPlaying(parent as? Album, isPlaying) + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + // Only highlight the album if it is currently playing, and if the currently + // playing song is also contained within. + val album = (parent as? Album)?.takeIf { song?.album == it } + albumAdapter.setPlaying(album, isPlaying) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 7eb5c88a0..e270fa7d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -28,8 +28,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView -import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.ArtistViewHolder @@ -39,11 +39,11 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.nonZeroOrNull /** @@ -78,7 +78,8 @@ class ArtistListFragment : collectImmediately(homeModel.artistsList, ::updateArtists) collectImmediately(selectionModel.selected, ::updateSelection) - collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collectImmediately( + playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { @@ -121,16 +122,18 @@ class ArtistListFragment : } private fun updateArtists(artists: List) { - artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) }) + artistAdapter.update(artists, homeModel.artistsInstructions.consume()) } private fun updateSelection(selection: List) { artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) } - private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - // If an artist is playing, highlight it within this adapter. - artistAdapter.setPlaying(parent as? Artist, isPlaying) + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + // Only highlight the artist if it is currently playing, and if the currently + // playing song is also contained within. + val artist = (parent as? Artist)?.takeIf { song?.run { artists.contains(it) } ?: false } + artistAdapter.setPlaying(artist, isPlaying) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index 8b2cab6f3..ee9544d55 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -28,8 +28,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView -import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.GenreViewHolder @@ -39,11 +39,11 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD /** * A [ListFragment] that shows a list of [Genre]s. @@ -77,7 +77,8 @@ class GenreListFragment : collectImmediately(homeModel.genresList, ::updateGenres) collectImmediately(selectionModel.selected, ::updateSelection) - collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collectImmediately( + playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { @@ -120,16 +121,18 @@ class GenreListFragment : } private fun updateGenres(genres: List) { - genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) }) + genreAdapter.update(genres, homeModel.genresInstructions.consume()) } private fun updateSelection(selection: List) { genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) } - private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - // If a genre is playing, highlight it within this adapter. - genreAdapter.setPlaying(parent as? Genre, isPlaying) + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + // Only highlight the genre if it is currently playing, and if the currently + // playing song is also contained within. + val genre = (parent as? Genre)?.takeIf { song?.run { genres.contains(it) } ?: false } + genreAdapter.setPlaying(genre, isPlaying) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index 4c5d8d19a..61fa54b7c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -27,8 +27,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView -import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.PlaylistViewHolder @@ -38,18 +38,16 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD /** * A [ListFragment] that shows a list of [Playlist]s. * * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Show a placeholder when there are no playlists. */ class PlaylistListFragment : ListFragment(), @@ -77,7 +75,8 @@ class PlaylistListFragment : collectImmediately(homeModel.playlistsList, ::updatePlaylists) collectImmediately(selectionModel.selected, ::updateSelection) - collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collectImmediately( + playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { @@ -120,17 +119,18 @@ class PlaylistListFragment : } private fun updatePlaylists(playlists: List) { - playlistAdapter.update( - playlists, homeModel.playlistsInstructions.consume().also { logD(it) }) + playlistAdapter.update(playlists, homeModel.playlistsInstructions.consume()) } private fun updateSelection(selection: List) { playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) } - private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - // If a playlist is playing, highlight it within this adapter. - playlistAdapter.setPlaying(parent as? Playlist, isPlaying) + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + // Only highlight the playlist if it is currently playing, and if the currently + // playing song is also contained within. + val playlist = (parent as? Playlist)?.takeIf { it.songs.contains(song) } + playlistAdapter.setPlaying(playlist, isPlaying) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index a21a470df..62643f4cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -30,8 +30,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView -import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder @@ -155,12 +155,8 @@ class SongListFragment : } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - if (parent == null) { - songAdapter.setPlaying(song, isPlaying) - } else { - // Ignore playback that is not from all songs - songAdapter.setPlaying(null, isPlaying) - } + // Only indicate playback that is from all songs + songAdapter.setPlaying(song.takeIf { parent == null }, isPlaying) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 718c99855..36aed93bf 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -23,7 +23,6 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.util.logD /** * A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations @@ -67,20 +66,11 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : // Use expected sw* size thresholds when choosing a configuration. when { // On small screens, only display an icon. - width < 370 -> { - logD("Using icon-only configuration") - tab.setIcon(icon).setContentDescription(string) - } + width < 370 -> tab.setIcon(icon).setContentDescription(string) // On large screens, display an icon and text. - width < 600 -> { - logD("Using text-only configuration") - tab.setText(string) - } + width < 600 -> tab.setText(string) // On medium-size screens, display text. - else -> { - logD("Using icon-and-text configuration") - tab.setIcon(icon).setText(string) - } + else -> tab.setIcon(icon).setText(string) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index 30425e6d8..2fddd1b4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.home.tabs import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.logW /** * A representation of a library tab suitable for configuration. @@ -84,6 +85,10 @@ sealed class Tab(open val mode: MusicMode) { fun toIntCode(tabs: Array): Int { // Like when deserializing, make sure there are no duplicate tabs for whatever reason. val distinct = tabs.distinctBy { it.mode } + if (tabs.size != distinct.size) { + logW( + "Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]") + } var sequence = 0 var shift = MAX_SEQUENCE_IDX * 4 @@ -127,6 +132,10 @@ sealed class Tab(open val mode: MusicMode) { // Make sure there are no duplicate tabs val distinct = tabs.distinctBy { it.mode } + if (tabs.size != distinct.size) { + logW( + "Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]") + } // For safety, return null if we have an empty or larger-than-expected tab array. if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt index 9e778cca1..277c0c39b 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt @@ -28,6 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration. @@ -52,6 +53,7 @@ class TabAdapter(private val listener: EditClickListListener) : * @param newTabs The new array of tabs to show. */ fun submitTabs(newTabs: Array) { + logD("Force-updating tab information") tabs = newTabs @Suppress("NotifyDatasetChanged") notifyDataSetChanged() } @@ -63,6 +65,7 @@ class TabAdapter(private val listener: EditClickListListener) : * @param tab The new tab. */ fun setTab(at: Int, tab: Tab) { + logD("Updating tab [at: $at, tab: $tab]") tabs[at] = tab // Use a payload to avoid an item change animation. notifyItemChanged(at, PAYLOAD_TAB_CHANGED) @@ -75,6 +78,7 @@ class TabAdapter(private val listener: EditClickListListener) : * @param b The position of the second tab to swap. */ fun swapTabs(a: Int, b: Int) { + logD("Swapping tabs [a: $a, b: $b]") val tmp = tabs[b] tabs[b] = tabs[a] tabs[a] = tmp diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt index dae73e93e..c7dadd8d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt @@ -91,14 +91,15 @@ class TabCustomizeDialog : // We will need the exact index of the tab to update on in order to // notify the adapter of the change. val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode } - val tab = tabAdapter.tabs[index] - tabAdapter.setTab( - index, - when (tab) { + val old = tabAdapter.tabs[index] + val new = + when (old) { // Invert the visibility of the tab - is Tab.Visible -> Tab.Invisible(tab.mode) - is Tab.Invisible -> Tab.Visible(tab.mode) - }) + is Tab.Visible -> Tab.Invisible(old.mode) + is Tab.Invisible -> Tab.Visible(old.mode) + } + logD("Flipping tab visibility [from: $old to: $new]") + tabAdapter.setTab(index, new) // Prevent the user from saving if all the tabs are Invisible, as that's an invalid state. (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt index 064d5f8dd..49af3b57b 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt @@ -63,7 +63,9 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac return true } - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + throw IllegalStateException() + } // We use a custom drag handle, so disable the long press action. override fun isLongPressDragEnabled() = false 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 bd19c3a87..ad81c25a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -27,7 +27,6 @@ import coil.request.ImageRequest import coil.size.Size import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.Song /** @@ -97,16 +96,11 @@ constructor( ImageRequest.Builder(context) .data(listOf(song)) // Use ORIGINAL sizing, as we are not loading into any View-like component. - .size(Size.ORIGINAL) - .transformations(SquareFrameTransform.INSTANCE)) - // Override the target in order to deliver the bitmap to the given - // listener. + .size(Size.ORIGINAL)) .target( onSuccess = { synchronized(this) { if (currentHandle == handle) { - // Has not been superseded by a new request, can deliver - // this result. target.onCompleted(it.toBitmap()) } } @@ -114,8 +108,6 @@ constructor( onError = { synchronized(this) { if (currentHandle == handle) { - // Has not been superseded by a new request, can deliver - // this result. target.onCompleted(null) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt new file mode 100644 index 000000000..a4d0e6917 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2023 Auxio Project + * CoverView.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Matrix +import android.graphics.PixelFormat +import android.graphics.RectF +import android.graphics.drawable.AnimationDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.annotation.AttrRes +import androidx.annotation.DimenRes +import androidx.annotation.DrawableRes +import androidx.core.content.res.getIntOrThrow +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.children +import androidx.core.view.updateMarginsRelative +import androidx.core.widget.ImageViewCompat +import coil.ImageLoader +import coil.request.ImageRequest +import coil.util.CoilUtils +import com.google.android.material.R as MR +import com.google.android.material.shape.MaterialShapeDrawable +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.ui.UISettings +import org.oxycblt.auxio.util.getAttrColorCompat +import org.oxycblt.auxio.util.getColorCompat +import org.oxycblt.auxio.util.getDimen +import org.oxycblt.auxio.util.getDimenPixels +import org.oxycblt.auxio.util.getDrawableCompat +import org.oxycblt.auxio.util.getInteger + +/** + * Auxio's extension of [ImageView] that enables cover art loading and playing indicator and + * selection badge. In practice, it's three [ImageView]'s in a [FrameLayout] trenchcoat. By default, + * all of this functionality is enabled. The playback indicator and selection badge selectively + * disabled with the "playbackIndicatorEnabled" and "selectionBadgeEnabled" attributes, and image + * itself can be overridden if populated like a normal [FrameLayout]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class CoverView +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr) { + @Inject lateinit var imageLoader: ImageLoader + @Inject lateinit var uiSettings: UISettings + + private val image: ImageView + + data class PlaybackIndicator( + val view: ImageView, + val playingDrawable: AnimationDrawable, + val pausedDrawable: Drawable + ) + private val playbackIndicator: PlaybackIndicator? + private val selectionBadge: ImageView? + + @DimenRes private val iconSizeRes: Int? + @DimenRes private val cornerRadiusRes: Int? + + private var fadeAnimator: ValueAnimator? = null + private val indicatorMatrix = Matrix() + private val indicatorMatrixSrc = RectF() + private val indicatorMatrixDst = RectF() + + init { + // Obtain some StyledImageView attributes to use later when theming the custom view. + @SuppressLint("CustomViewStyleable") + val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView) + + val sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing) + iconSizeRes = SIZING_ICON_SIZE[sizing] + cornerRadiusRes = + if (uiSettings.roundMode) { + SIZING_CORNER_RADII[sizing] + } else { + null + } + + val playbackIndicatorEnabled = + styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true) + val selectionBadgeEnabled = + styledAttrs.getBoolean(R.styleable.CoverView_enableSelectionBadge, true) + + styledAttrs.recycle() + + image = ImageView(context, attrs) + + // Initialize the playback indicator if enabled. + playbackIndicator = + if (playbackIndicatorEnabled) { + PlaybackIndicator( + ImageView(context).apply { + scaleType = ImageView.ScaleType.MATRIX + ImageViewCompat.setImageTintList( + this, context.getColorCompat(R.color.sel_on_cover_bg)) + }, + context.getDrawableCompat(R.drawable.ic_playing_indicator_24) + as AnimationDrawable, + context.getDrawableCompat(R.drawable.ic_paused_indicator_24)) + } else { + null + } + + // Initialize the selection badge if enabled. + selectionBadge = + if (selectionBadgeEnabled) { + ImageView(context).apply { + imageTintList = context.getAttrColorCompat(MR.attr.colorOnPrimary) + setImageResource(R.drawable.ic_check_20) + setBackgroundResource(R.drawable.ui_selection_badge_bg) + } + } else { + null + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + + // The image isn't added if other children have populated the body. This is by design. + if (childCount == 0) { + addView(image) + } + + playbackIndicator?.run { addView(view) } + + // Add backgrounds to each child for visual consistency + for (child in children) { + child.apply { + // If there are rounded corners, we want to make sure view content will be cropped + // with it. + clipToOutline = this != image + background = + MaterialShapeDrawable().apply { + fillColor = context.getColorCompat(R.color.sel_cover_bg) + setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f) + } + } + } + + // The selection badge has it's own background we don't want overridden, add it after + // all other elements. + selectionBadge?.let { + addView( + it, + // Position the selection badge to the bottom right. + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + // Override the layout params of the indicator so that it's in the + // bottom left corner. + gravity = Gravity.BOTTOM or Gravity.END + val spacing = context.getDimenPixels(R.dimen.spacing_tiny) + updateMarginsRelative(bottom = spacing, end = spacing) + }) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + // AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the + // behavior with a matrix. + val playbackIndicator = (playbackIndicator ?: return).view + val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2) + playbackIndicator.apply { + imageMatrix = + indicatorMatrix.apply { + reset() + drawable?.let { drawable -> + // First scale the icon up to the desired size. + indicatorMatrixSrc.set( + 0f, + 0f, + drawable.intrinsicWidth.toFloat(), + drawable.intrinsicHeight.toFloat()) + indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat()) + indicatorMatrix.setRectToRect( + indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER) + + // Then actually center it into the icon. + indicatorMatrix.postTranslate( + (measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f) + } + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + invalidateRootAlpha() + invalidatePlaybackIndicatorAlpha(playbackIndicator ?: return) + invalidateSelectionIndicatorAlpha(selectionBadge ?: return) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + invalidateRootAlpha() + } + + override fun setSelected(selected: Boolean) { + super.setSelected(selected) + invalidateRootAlpha() + invalidatePlaybackIndicatorAlpha(playbackIndicator ?: return) + } + + override fun setActivated(activated: Boolean) { + super.setActivated(activated) + invalidateSelectionIndicatorAlpha(selectionBadge ?: return) + } + + /** + * Set if the playback indicator should be indicated ongoing or paused playback. + * + * @param isPlaying Whether playback is ongoing or paused. + */ + fun setPlaying(isPlaying: Boolean) { + playbackIndicator?.run { + if (isPlaying) { + playingDrawable.start() + view.setImageDrawable(playingDrawable) + } else { + playingDrawable.stop() + view.setImageDrawable(pausedDrawable) + } + } + } + + private fun invalidateRootAlpha() { + alpha = if (isEnabled || isSelected) 1f else 0.5f + } + + private fun invalidatePlaybackIndicatorAlpha(playbackIndicator: PlaybackIndicator) { + // Occasionally content can bleed through the rounded corners and result in a seam + // on the playing indicator, prevent that from occurring by disabling the visibility of + // all views below the playback indicator. + for (child in children) { + child.alpha = + when (child) { + // Selection badge is above the playback indicator, do nothing + selectionBadge -> child.alpha + playbackIndicator.view -> if (isSelected) 1f else 0f + else -> if (isSelected) 0f else 1f + } + } + } + + private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) { + // Set up a target transition for the selection indicator. + val targetAlpha: Float + val targetDuration: Long + + if (isActivated) { + // View is "activated" (i.e marked as selected), so show the selection indicator. + targetAlpha = 1f + targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong() + } else { + // View is not "activated", hide the selection indicator. + targetAlpha = 0f + targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong() + } + + if (selectionBadge.alpha == targetAlpha) { + // Nothing to do. + return + } + + if (!isLaidOut) { + // Not laid out, initialize it without animation before drawing. + selectionBadge.alpha = targetAlpha + return + } + + if (fadeAnimator != null) { + // Cancel any previous animation. + fadeAnimator?.cancel() + fadeAnimator = null + } + + fadeAnimator = + ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply { + duration = targetDuration + addUpdateListener { selectionBadge.alpha = it.animatedValue as Float } + start() + } + } + + /** + * Bind a [Song]'s image to this view. + * + * @param song The [Song] to bind to the view. + */ + fun bind(song: Song) = + bind( + listOf(song), + context.getString(R.string.desc_album_cover, song.album.name), + R.drawable.ic_album_24) + + /** + * Bind an [Album]'s image to this view. + * + * @param album The [Album] to bind to the view. + */ + fun bind(album: Album) = + bind( + album.songs, + context.getString(R.string.desc_album_cover, album.name), + R.drawable.ic_album_24) + + /** + * Bind an [Artist]'s image to this view. + * + * @param artist The [Artist] to bind to the view. + */ + fun bind(artist: Artist) = + bind( + artist.songs, + context.getString(R.string.desc_artist_image, artist.name), + R.drawable.ic_artist_24) + + /** + * Bind a [Genre]'s image to this view. + * + * @param genre The [Genre] to bind to the view. + */ + fun bind(genre: Genre) = + bind( + genre.songs, + context.getString(R.string.desc_genre_image, genre.name), + R.drawable.ic_genre_24) + + /** + * Bind a [Playlist]'s image to this view. + * + * @param playlist the [Playlist] to bind. + */ + fun bind(playlist: Playlist) = + bind( + playlist.songs, + context.getString(R.string.desc_playlist_image, playlist.name), + R.drawable.ic_playlist_24) + + /** + * Bind the covers of a generic list of [Song]s. + * + * @param songs The [Song]s to bind. + * @param desc The content description to describe the bound data. + * @param errorRes The resource of the error drawable to use if the cover cannot be loaded. + */ + fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) { + val request = + ImageRequest.Builder(context) + .data(songs) + .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes)) + .transformations( + RoundedCornersTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)) + .target(image) + .build() + // Dispose of any previous image request and load a new image. + CoilUtils.dispose(image) + imageLoader.enqueue(request) + contentDescription = desc + } + + /** + * Since the error drawable must also share a view with an image, any kind of transform or tint + * must occur within a custom dialog, which is implemented here. + */ + private class StyledDrawable( + context: Context, + private val inner: Drawable, + @DimenRes iconSizeRes: Int? + ) : Drawable() { + init { + // Re-tint the drawable to use the analogous "on surface" color for + // StyledImageView. + DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) + } + + private val dimen = iconSizeRes?.let(context::getDimenPixels) + + override fun draw(canvas: Canvas) { + // Resize the drawable such that it's always 1/4 the size of the image and + // centered in the middle of the canvas. + val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4) + inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj) + inner.draw(canvas) + } + + // Required drawable overrides. Just forward to the wrapped drawable. + + override fun setAlpha(alpha: Int) { + inner.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + inner.colorFilter = colorFilter + } + + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + } + + companion object { + val SIZING_CORNER_RADII = + arrayOf( + R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium) + val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt deleted file mode 100644 index 449f489fc..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ImageGroup.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.image - -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.Gravity -import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import androidx.annotation.AttrRes -import androidx.core.view.updateMarginsRelative -import com.google.android.material.shape.MaterialShapeDrawable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.util.getAttrColorCompat -import org.oxycblt.auxio.util.getColorCompat -import org.oxycblt.auxio.util.getDimenPixels -import org.oxycblt.auxio.util.getInteger - -/** - * A super-charged [StyledImageView]. This class enables the following features in addition to - * [StyledImageView]: - * - A selection indicator - * - An activation (playback) indicator - * - Support for ONE custom view - * - * This class is primarily intended for list items. For other uses, [StyledImageView] is more - * suitable. - * - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Rework content descriptions here - * TODO: Attempt unification with StyledImageView with some kind of dynamic configuration to avoid - * superfluous elements - * TODO: Handle non-square covers by gracefully placing them in the layout - */ -class ImageGroup -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : - FrameLayout(context, attrs, defStyleAttr) { - private val innerImageView: StyledImageView - private var customView: View? = null - private val playbackIndicatorView: PlaybackIndicatorView - private val selectionIndicatorView: ImageView - - private var fadeAnimator: ValueAnimator? = null - private val cornerRadius: Float - - init { - // Obtain some StyledImageView attributes to use later when theming the custom view. - @SuppressLint("CustomViewStyleable") - val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) - // Keep track of our corner radius so that we can apply the same attributes to the custom - // view. - cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) - styledAttrs.recycle() - - // Initialize what views we can here. - innerImageView = StyledImageView(context, attrs) - playbackIndicatorView = - PlaybackIndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius } - selectionIndicatorView = - ImageView(context).apply { - imageTintList = context.getAttrColorCompat(R.attr.colorOnPrimary) - setImageResource(R.drawable.ic_check_20) - setBackgroundResource(R.drawable.ui_selection_badge_bg) - } - - // The inner StyledImageView should be at the bottom and hidden by any other elements - // if they become visible. - addView(innerImageView) - } - - override fun onFinishInflate() { - super.onFinishInflate() - // Due to innerImageView, the max child count is actually 2 and not 1. - check(childCount < 3) { "Only one custom view is allowed" } - - // Get the second inflated child, making sure we customize it to align with - // the rest of this view. - customView = - getChildAt(1)?.apply { - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - setCornerSize(cornerRadius) - } - } - - // Playback indicator should sit above the inner StyledImageView and custom view/ - addView(playbackIndicatorView) - // Selection indicator should never be obscured, so place it at the top. - addView( - selectionIndicatorView, - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - // Override the layout params of the indicator so that it's in the - // bottom left corner. - gravity = Gravity.BOTTOM or Gravity.END - val spacing = context.getDimenPixels(R.dimen.spacing_tiny) - updateMarginsRelative(bottom = spacing, end = spacing) - }) - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - // Initialize each component before this view is drawn. - invalidateImageAlpha() - invalidatePlayingIndicator() - invalidateSelectionIndicator() - } - - override fun setActivated(activated: Boolean) { - super.setActivated(activated) - invalidateSelectionIndicator() - } - - override fun setEnabled(enabled: Boolean) { - super.setEnabled(enabled) - invalidateImageAlpha() - invalidatePlayingIndicator() - } - - override fun setSelected(selected: Boolean) { - super.setSelected(selected) - invalidateImageAlpha() - invalidatePlayingIndicator() - } - - /** - * Bind a [Song] to the internal [StyledImageView]. - * - * @param song The [Song] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(song: Song) = innerImageView.bind(song) - - /** - * Bind a [Album] to the internal [StyledImageView]. - * - * @param album The [Album] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(album: Album) = innerImageView.bind(album) - - /** - * Bind a [Genre] to the internal [StyledImageView]. - * - * @param artist The [Artist] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(artist: Artist) = innerImageView.bind(artist) - - /** - * Bind a [Genre] to the internal [StyledImageView]. - * - * @param genre The [Genre] to bind to the view. - * @see StyledImageView.bind - */ - fun bind(genre: Genre) = innerImageView.bind(genre) - - /** - * Bind a [Playlist]'s image to the internal [StyledImageView]. - * - * @param playlist the [Playlist] to bind. - * @see StyledImageView.bind - */ - fun bind(playlist: Playlist) = innerImageView.bind(playlist) - - /** - * Whether this view should be indicated to have ongoing playback or not. See - * PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this - * view to already be marked as playing with setSelected (not the same thing) before this is set - * to true. - */ - var isPlaying: Boolean - get() = playbackIndicatorView.isPlaying - set(value) { - playbackIndicatorView.isPlaying = value - } - - private fun invalidateImageAlpha() { - // If this view is disabled, show it at half-opacity, *unless* it is also marked - // as playing, in which we still want to show it at full-opacity. - alpha = if (isSelected || isEnabled) 1f else 0.5f - } - - private fun invalidatePlayingIndicator() { - if (isSelected) { - // View is "selected" (actually marked as playing), so show the playing indicator - // and hide all other elements except for the selection indicator. - // TODO: Animate the other indicators? - customView?.alpha = 0f - innerImageView.alpha = 0f - playbackIndicatorView.alpha = 1f - } else { - // View is not "selected", hide the playing indicator. - customView?.alpha = 1f - innerImageView.alpha = 1f - playbackIndicatorView.alpha = 0f - } - } - - private fun invalidateSelectionIndicator() { - // Set up a target transition for the selection indicator. - val targetAlpha: Float - val targetDuration: Long - - if (isActivated) { - // View is "activated" (i.e marked as selected), so show the selection indicator. - targetAlpha = 1f - targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong() - } else { - // View is not "activated", hide the selection indicator. - targetAlpha = 0f - targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong() - } - - if (selectionIndicatorView.alpha == targetAlpha) { - // Nothing to do. - return - } - - if (!isLaidOut) { - // Not laid out, initialize it without animation before drawing. - selectionIndicatorView.alpha = targetAlpha - return - } - - if (fadeAnimator != null) { - // Cancel any previous animation. - fadeAnimator?.cancel() - fadeAnimator = null - } - - fadeAnimator = - ValueAnimator.ofFloat(selectionIndicatorView.alpha, targetAlpha).apply { - duration = targetDuration - addUpdateListener { selectionIndicatorView.alpha = it.animatedValue as Float } - start() - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt index b3bb1cf2f..a45fc4cbb 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt @@ -22,7 +22,6 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import org.oxycblt.auxio.image.extractor.* @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt index 7f1aca57f..a4c13c4c9 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt @@ -73,6 +73,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context override fun onSettingChanged(key: String, listener: ImageSettings.Listener) { if (key == getString(R.string.set_key_cover_mode)) { + logD("Dispatching cover mode setting change") listener.onCoverModeChanged() } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt deleted file mode 100644 index 68c9bcd44..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * PlaybackIndicatorView.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.image - -import android.content.Context -import android.graphics.Matrix -import android.graphics.RectF -import android.graphics.drawable.AnimationDrawable -import android.util.AttributeSet -import androidx.annotation.AttrRes -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.widget.ImageViewCompat -import com.google.android.material.shape.MaterialShapeDrawable -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlin.math.max -import org.oxycblt.auxio.R -import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.util.getColorCompat -import org.oxycblt.auxio.util.getDrawableCompat - -/** - * A view that displays an activation (i.e playback) indicator, with an accented styling and an - * animated equalizer icon. - * - * This is only meant for use with [ImageGroup]. Due to limitations with [AnimationDrawable] - * instances within custom views, this cannot be merged with [ImageGroup]. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class PlaybackIndicatorView -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : - AppCompatImageView(context, attrs, defStyleAttr) { - private val playingIndicatorDrawable = - context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable - private val pausedIndicatorDrawable = - context.getDrawableCompat(R.drawable.ic_paused_indicator_24) - private val indicatorMatrix = Matrix() - private val indicatorMatrixSrc = RectF() - private val indicatorMatrixDst = RectF() - @Inject lateinit var uiSettings: UISettings - - /** - * The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius - * to this view without any attribute hacks. - */ - var cornerRadius = 0f - set(value) { - field = value - (background as? MaterialShapeDrawable)?.let { bg -> - if (uiSettings.roundMode) { - bg.setCornerSize(value) - } else { - bg.setCornerSize(0f) - } - } - } - - /** - * Whether this view should be indicated to have ongoing playback or not. If true, the animated - * playing icon will be shown. If false, the static paused icon will be shown. - */ - var isPlaying: Boolean - get() = drawable == playingIndicatorDrawable - set(value) { - if (value) { - playingIndicatorDrawable.start() - setImageDrawable(playingIndicatorDrawable) - } else { - playingIndicatorDrawable.stop() - setImageDrawable(pausedIndicatorDrawable) - } - } - - init { - // We will need to manually re-scale the playing/paused drawables to align with - // StyledDrawable, so use the matrix scale type. - scaleType = ScaleType.MATRIX - // Tint the playing/paused drawables so they are harmonious with the background. - ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg)) - - // Use clipToOutline and a background drawable to crop images. While Coil's transformation - // could theoretically be used to round corners, the corner radius is dependent on the - // dimensions of the image, which will result in inconsistent corners across different - // album covers unless we resize all covers to be the same size. clipToOutline is both - // cheaper and more elegant. As a side-note, this also allows us to re-use the same - // background for both the tonal background color and the corner rounding. - clipToOutline = true - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - setCornerSize(cornerRadius) - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - // Emulate StyledDrawable scaling with matrix scaling. - val iconSize = max(measuredWidth, measuredHeight) / 2 - imageMatrix = - indicatorMatrix.apply { - reset() - drawable?.let { drawable -> - // First scale the icon up to the desired size. - indicatorMatrixSrc.set( - 0f, - 0f, - drawable.intrinsicWidth.toFloat(), - drawable.intrinsicHeight.toFloat()) - indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat()) - indicatorMatrix.setRectToRect( - indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER) - - // Then actually center it into the icon. - indicatorMatrix.postTranslate( - (measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f) - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt new file mode 100644 index 000000000..c25770ec4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2023 Auxio Project + * RoundedCornersTransformation.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image + +import android.graphics.Bitmap +import android.graphics.Bitmap.createBitmap +import android.graphics.BitmapShader +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.RectF +import android.graphics.Shader +import androidx.annotation.Px +import androidx.core.graphics.applyCanvas +import coil.decode.DecodeUtils +import coil.size.Scale +import coil.size.Size +import coil.size.pxOrElse +import coil.transform.Transformation +import kotlin.math.roundToInt + +/** + * A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio + * images without cropping them. + * + * @author Coil Team, Alexander Capehart (OxygenCobalt) + */ +class RoundedCornersTransformation( + @Px private val topLeft: Float = 0f, + @Px private val topRight: Float = 0f, + @Px private val bottomLeft: Float = 0f, + @Px private val bottomRight: Float = 0f +) : Transformation { + + constructor(@Px radius: Float) : this(radius, radius, radius, radius) + + init { + require(topLeft >= 0 && topRight >= 0 && bottomLeft >= 0 && bottomRight >= 0) { + "All radii must be >= 0." + } + } + + override val cacheKey = "${javaClass.name}-$topLeft,$topRight,$bottomLeft,$bottomRight" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + + val (outputWidth, outputHeight) = calculateOutputSize(input, size) + + val output = createBitmap(outputWidth, outputHeight, input.config) + output.applyCanvas { + drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + val matrix = Matrix() + val multiplier = + DecodeUtils.computeSizeMultiplier( + srcWidth = input.width, + srcHeight = input.height, + dstWidth = outputWidth, + dstHeight = outputHeight, + scale = Scale.FILL) + .toFloat() + val dx = (outputWidth - multiplier * input.width) / 2 + val dy = (outputHeight - multiplier * input.height) / 2 + matrix.setTranslate(dx, dy) + matrix.preScale(multiplier, multiplier) + + val shader = BitmapShader(input, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + shader.setLocalMatrix(matrix) + paint.shader = shader + + val radii = + floatArrayOf( + topLeft, + topLeft, + topRight, + topRight, + bottomRight, + bottomRight, + bottomLeft, + bottomLeft, + ) + val rect = RectF(0f, 0f, width.toFloat(), height.toFloat()) + val path = Path().apply { addRoundRect(rect, radii, Path.Direction.CW) } + drawPath(path, paint) + } + + return output + } + + private fun calculateOutputSize(input: Bitmap, size: Size): Pair { + // MODIFICATION: Remove short-circuiting for original size and input size + val multiplier = + DecodeUtils.computeSizeMultiplier( + srcWidth = input.width, + srcHeight = input.height, + dstWidth = size.width.pxOrElse { Int.MIN_VALUE }, + dstHeight = size.height.pxOrElse { Int.MIN_VALUE }, + scale = Scale.FIT) + val outputWidth = (multiplier * input.width).roundToInt() + val outputHeight = (multiplier * input.height).roundToInt() + return outputWidth to outputHeight + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is RoundedCornersTransformation && + topLeft == other.topLeft && + topRight == other.topRight && + bottomLeft == other.bottomLeft && + bottomRight == other.bottomRight + } + + override fun hashCode(): Int { + var result = topLeft.hashCode() + result = 31 * result + topRight.hashCode() + result = 31 * result + bottomLeft.hashCode() + result = 31 * result + bottomRight.hashCode() + return result + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt deleted file mode 100644 index 2e04617e5..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * StyledImageView.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.image - -import android.content.Context -import android.graphics.Canvas -import android.graphics.ColorFilter -import android.graphics.PixelFormat -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import androidx.annotation.AttrRes -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.DrawableCompat -import coil.ImageLoader -import coil.request.ImageRequest -import coil.util.CoilUtils -import com.google.android.material.shape.MaterialShapeDrawable -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import org.oxycblt.auxio.R -import org.oxycblt.auxio.image.extractor.SquareFrameTransform -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.util.getColorCompat -import org.oxycblt.auxio.util.getDrawableCompat - -/** - * An [AppCompatImageView] with some additional styling, including: - * - Tonal background - * - Rounded corners based on user preferences - * - Built-in support for binding image data or using a static icon with the same styling as - * placeholder drawables. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class StyledImageView -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : - AppCompatImageView(context, attrs, defStyleAttr) { - @Inject lateinit var imageLoader: ImageLoader - @Inject lateinit var uiSettings: UISettings - - init { - // Load view attributes - val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) - val staticIcon = - styledAttrs.getResourceId( - R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL) - val cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) - styledAttrs.recycle() - - if (staticIcon != ResourcesCompat.ID_NULL) { - // Use the static icon if specified for this image. - setImageDrawable(StyledDrawable(context, context.getDrawableCompat(staticIcon))) - } - - // Use clipToOutline and a background drawable to crop images. While Coil's transformation - // could theoretically be used to round corners, the corner radius is dependent on the - // dimensions of the image, which will result in inconsistent corners across different - // album covers unless we resize all covers to be the same size. clipToOutline is both - // cheaper and more elegant. As a side-note, this also allows us to re-use the same - // background for both the tonal background color and the corner rounding. - clipToOutline = true - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - if (uiSettings.roundMode) { - // Only use the specified corner radius when round mode is enabled. - setCornerSize(cornerRadius) - } - } - } - - /** - * Bind a [Song]'s album cover to this view, also updating the content description. - * - * @param song The [Song] to bind. - */ - fun bind(song: Song) = bind(song.album) - - /** - * Bind an [Album]'s cover to this view, also updating the content description. - * - * @param album the [Album] to bind. - */ - fun bind(album: Album) = bind(album, R.drawable.ic_album_24, R.string.desc_album_cover) - - /** - * Bind an [Artist]'s image to this view, also updating the content description. - * - * @param artist the [Artist] to bind. - */ - fun bind(artist: Artist) = bind(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) - - /** - * Bind an [Genre]'s image to this view, also updating the content description. - * - * @param genre the [Genre] to bind. - */ - fun bind(genre: Genre) = bind(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) - - /** - * Bind a [Playlist]'s image to this view, also updating the content description. - * - * @param playlist The [Playlist] to bind. - * @param songs [Song]s that can override the playlist image if it needs to differ for any - * reason. - */ - fun bind(playlist: Playlist, songs: List? = null) = - if (songs != null) { - bind( - songs, - context.getString(R.string.desc_playlist_image, playlist.name.resolve(context)), - R.drawable.ic_playlist_24) - } else { - bind(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image) - } - - private fun bind(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) { - bind(parent.songs, context.getString(descRes, parent.name.resolve(context)), errorRes) - } - - private fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) { - val request = - ImageRequest.Builder(context) - .data(songs) - .error(StyledDrawable(context, context.getDrawableCompat(errorRes))) - .transformations(SquareFrameTransform.INSTANCE) - .target(this) - .build() - // Dispose of any previous image request and load a new image. - CoilUtils.dispose(this) - imageLoader.enqueue(request) - contentDescription = desc - } - - /** - * A [Drawable] wrapper that re-styles the drawable to better align with the style of - * [StyledImageView]. - * - * @param context [Context] required for initialization. - * @param inner The [Drawable] to wrap. - */ - private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() { - init { - // Re-tint the drawable to use the analogous "on surface" color for - // StyledImageView. - DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) - } - - override fun draw(canvas: Canvas) { - // Resize the drawable such that it's always 1/4 the size of the image and - // centered in the middle of the canvas. - val adjustWidth = bounds.width() / 4 - val adjustHeight = bounds.height() / 4 - inner.bounds.set( - adjustWidth, - adjustHeight, - bounds.width() - adjustWidth, - bounds.height() - adjustHeight) - inner.draw(canvas) - } - - // Required drawable overrides. Just forward to the wrapped drawable. - - override fun setAlpha(alpha: Int) { - inner.alpha = alpha - } - - override fun setColorFilter(colorFilter: ColorFilter?) { - inner.colorFilter = colorFilter - } - - override fun getOpacity(): Int = PixelFormat.TRANSLUCENT - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 4e8e6d6d6..017a76747 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -24,12 +24,12 @@ import coil.key.Keyer import coil.request.Options import coil.size.Size import javax.inject.Inject -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Song class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : Keyer> { override fun key(data: List, options: Options) = - "${coverExtractor.computeAlbumOrdering(data).hashCode()}" + "${coverExtractor.computeCoverOrdering(data).hashCode()}" } class SongCoverFetcher diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 395429104..067b8361c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -43,6 +43,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.io.ByteArrayInputStream import java.io.InputStream import javax.inject.Inject +import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.asDeferred import kotlinx.coroutines.withContext @@ -50,11 +51,16 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.logE +/** + * Provides functionality for extracting album cover information. Meant for internal use only. + * + * @author Alexander Capehart (OxygenCobalt) + */ class CoverExtractor @Inject constructor( @@ -62,28 +68,69 @@ constructor( private val imageSettings: ImageSettings, private val mediaSourceFactory: MediaSource.Factory ) { + /** + * Extract an image (in the form of [FetchResult]) to represent the given [Song]s. + * + * @param songs The [Song]s to load. + * @param size The [Size] of the image to load. + * @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult] + * will be returned of a mosaic composed of four album covers ordered by + * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. + */ suspend fun extract(songs: List, size: Size): FetchResult? { - val albums = computeAlbumOrdering(songs) + val albums = computeCoverOrdering(songs) val streams = mutableListOf() for (album in albums) { - openInputStream(album)?.let(streams::add) + openCoverInputStream(album)?.let(streams::add) + // We don't immediately check for mosaic feasibility from album count alone, as that + // does not factor in InputStreams failing to load. Instead, only check once we + // definitely have image data to use. if (streams.size == 4) { - return createMosaic(streams, size) + // Make sure we free the InputStreams once we've transformed them into a mosaic. + return createMosaic(streams, size).also { + withContext(Dispatchers.IO) { streams.forEach(InputStream::close) } + } } } - return streams.firstOrNull()?.let { stream -> - SourceResult( - source = ImageSource(stream.source().buffer(), context), - mimeType = null, - dataSource = DataSource.DISK) + // Not enough covers for a mosaic, take the first one (if that even exists) + val first = streams.firstOrNull() ?: return null + + // All but the first stream will be unused, free their resources + withContext(Dispatchers.IO) { + for (i in 1 until streams.size) { + streams[i].close() + } } + + return SourceResult( + source = ImageSource(first.source().buffer(), context), + mimeType = null, + dataSource = DataSource.DISK) } - fun computeAlbumOrdering(songs: List) = - songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } + /** + * Creates an [Album] list representing the order that album covers would be used in [extract]. + * + * @param songs A hypothetical list of [Song]s that would be used in [extract]. + * @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then + * by their names. "Representation" is defined by how many [Song]s were found to be linked to + * the given [Album] in the given [Song] list. + */ + fun computeCoverOrdering(songs: List): List { + // TODO: Start short-circuiting in more places + if (songs.isEmpty()) return listOf() + if (songs.size == 1) return listOf(songs.first().album) - private suspend fun openInputStream(album: Album): InputStream? = + val sortedMap = + sortedMapOf(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING)) + for (song in songs) { + sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1 + } + return sortedMap.keys.sortedByDescending { sortedMap[it] } + } + + private suspend fun openCoverInputStream(album: Album) = try { when (imageSettings.coverMode) { CoverMode.OFF -> null @@ -91,7 +138,7 @@ constructor( CoverMode.QUALITY -> extractQualityCover(album) } } catch (e: Exception) { - logW("Unable to extract album cover due to an error: $e") + logE("Unable to extract album cover due to an error: $e") null } @@ -148,7 +195,6 @@ constructor( } if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) { - logD("Front cover found") stream = ByteArrayInputStream(pic) break } else if (stream == null) { @@ -164,7 +210,7 @@ constructor( withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) } /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ - private suspend fun createMosaic(streams: List, size: Size): FetchResult { + private fun createMosaic(streams: List, size: Size): FetchResult { // Use whatever size coil gives us to create the mosaic. val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) val mosaicFrameSize = @@ -184,10 +230,9 @@ constructor( break } - // Run the bitmap through a transform to reflect the configuration of other images. - val bitmap = - SquareFrameTransform.INSTANCE.transform( - BitmapFactory.decodeStream(stream), mosaicFrameSize) + // Crop the bitmap down to a square so it leaves no empty space + // TODO: Work around this + val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize) canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) x += bitmap.width @@ -206,14 +251,27 @@ constructor( dataSource = DataSource.DISK) } - /** - * Get an image dimension suitable to create a mosaic with. - * - * @return A pixel dimension derived from the given [Dimension] that will always be even, - * allowing it to be sub-divided. - */ private fun Dimension.mosaicSize(): Int { + // Since we want the mosaic to be perfectly divisible into two, we need to round any + // odd image sizes upwards to prevent the mosaic creation from failing. val size = pxOrElse { 512 } return if (size.mod(2) > 0) size + 1 else size } + + private fun cropBitmap(input: Bitmap, size: Size): Bitmap { + // Find the smaller dimension and then take a center portion of the image that + // has that size. + val dstSize = min(input.width, input.height) + val x = (input.width - dstSize) / 2 + val y = (input.height - dstSize) / 2 + val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) + + val desiredWidth = size.width.pxOrElse { dstSize } + val desiredHeight = size.height.pxOrElse { dstSize } + if (dstSize != desiredWidth || dstSize != desiredHeight) { + // Image is not the desired size, upscale it. + return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) + } + return dst + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt deleted file mode 100644 index bdc48b49a..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * SquareFrameTransform.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.image.extractor - -import android.graphics.Bitmap -import coil.size.Size -import coil.size.pxOrElse -import coil.transform.Transformation -import kotlin.math.min - -/** - * A transformation that performs a center crop-style transformation on an image. Allowing this - * behavior to be intrinsic without any view configuration. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class SquareFrameTransform : Transformation { - override val cacheKey: String - get() = "SquareFrameTransform" - - override suspend fun transform(input: Bitmap, size: Size): Bitmap { - // Find the smaller dimension and then take a center portion of the image that - // has that size. - val dstSize = min(input.width, input.height) - val x = (input.width - dstSize) / 2 - val y = (input.height - dstSize) / 2 - val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) - - val desiredWidth = size.width.pxOrElse { dstSize } - val desiredHeight = size.height.pxOrElse { dstSize } - if (dstSize != desiredWidth || dstSize != desiredHeight) { - // Image is not the desired size, upscale it. - return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) - } - return dst - } - - companion object { - /** A re-usable instance. */ - val INSTANCE = SquareFrameTransform() - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt index e41dd4149..8636e1579 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.list import androidx.annotation.StringRes +// TODO: Consider breaking this up into sealed classes for individual adapters /** A marker for something that is a RecyclerView item. Has no functionality on it's own. */ interface Item diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index 49655d01b..bca4fb774 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.list -import android.view.MenuItem import android.view.View import androidx.annotation.MenuRes import androidx.appcompat.widget.PopupMenu @@ -28,10 +27,17 @@ import androidx.viewbinding.ViewBinding import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.list.selection.SelectionFragment -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.share import org.oxycblt.auxio.util.showToast /** @@ -83,32 +89,45 @@ abstract class ListFragment : protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) { logD("Launching new song menu: ${song.name}") - openMusicMenuImpl(anchor, menuRes) { - when (it.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(song) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(song) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_go_artist -> { - navModel.exploreNavigateToParentArtist(song) - } - R.id.action_go_album -> { - navModel.exploreNavigateTo(song.album) - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(song) - } - R.id.action_song_detail -> { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.actionShowDetails(song.uid))) - } - else -> { - error("Unexpected menu item selected") + openMenu(anchor, menuRes) { + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_play_next -> { + playbackModel.playNext(song) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(song) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_go_artist -> { + navModel.exploreNavigateToParentArtist(song) + true + } + R.id.action_go_album -> { + navModel.exploreNavigateTo(song.album) + true + } + R.id.action_share -> { + requireContext().share(song) + true + } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(song) + true + } + R.id.action_song_detail -> { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionShowDetails(song.uid))) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } } @@ -125,30 +144,43 @@ abstract class ListFragment : protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) { logD("Launching new album menu: ${album.name}") - openMusicMenuImpl(anchor, menuRes) { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(album) - } - R.id.action_shuffle -> { - playbackModel.shuffle(album) - } - R.id.action_play_next -> { - playbackModel.playNext(album) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(album) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_go_artist -> { - navModel.exploreNavigateToParentArtist(album) - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(album) - } - else -> { - error("Unexpected menu item selected") + openMenu(anchor, menuRes) { + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_play -> { + playbackModel.play(album) + true + } + R.id.action_shuffle -> { + playbackModel.shuffle(album) + true + } + R.id.action_play_next -> { + playbackModel.playNext(album) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(album) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_go_artist -> { + navModel.exploreNavigateToParentArtist(album) + true + } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(album) + true + } + R.id.action_share -> { + requireContext().share(album) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } } @@ -165,27 +197,50 @@ abstract class ListFragment : protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) { logD("Launching new artist menu: ${artist.name}") - openMusicMenuImpl(anchor, menuRes) { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(artist) - } - R.id.action_shuffle -> { - playbackModel.shuffle(artist) - } - R.id.action_play_next -> { - playbackModel.playNext(artist) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(artist) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(artist) - } - else -> { - error("Unexpected menu item selected") + openMenu(anchor, menuRes) { + val playable = artist.songs.isNotEmpty() + if (!playable) { + logD("Artist is empty, disabling playback/playlist/share options") + } + menu.findItem(R.id.action_play).isEnabled = playable + menu.findItem(R.id.action_shuffle).isEnabled = playable + menu.findItem(R.id.action_play_next).isEnabled = playable + menu.findItem(R.id.action_queue_add).isEnabled = playable + menu.findItem(R.id.action_playlist_add).isEnabled = playable + menu.findItem(R.id.action_share).isEnabled = playable + + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_play -> { + playbackModel.play(artist) + true + } + R.id.action_shuffle -> { + playbackModel.shuffle(artist) + true + } + R.id.action_play_next -> { + playbackModel.playNext(artist) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(artist) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(artist) + true + } + R.id.action_share -> { + requireContext().share(artist) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } } @@ -202,27 +257,39 @@ abstract class ListFragment : protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) { logD("Launching new genre menu: ${genre.name}") - openMusicMenuImpl(anchor, menuRes) { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(genre) - } - R.id.action_shuffle -> { - playbackModel.shuffle(genre) - } - R.id.action_play_next -> { - playbackModel.playNext(genre) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(genre) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(genre) - } - else -> { - error("Unexpected menu item selected") + openMenu(anchor, menuRes) { + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_play -> { + playbackModel.play(genre) + true + } + R.id.action_shuffle -> { + playbackModel.shuffle(genre) + true + } + R.id.action_play_next -> { + playbackModel.playNext(genre) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(genre) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(genre) + true + } + R.id.action_share -> { + requireContext().share(genre) + true + } + else -> { + logW("Unexpected menu item selected") + false + } } } } @@ -239,44 +306,51 @@ abstract class ListFragment : protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) { logD("Launching new playlist menu: ${playlist.name}") - openMusicMenuImpl(anchor, menuRes) { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(playlist) - } - R.id.action_shuffle -> { - playbackModel.shuffle(playlist) - } - R.id.action_play_next -> { - playbackModel.playNext(playlist) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(playlist) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_rename -> { - musicModel.renamePlaylist(playlist) - } - R.id.action_delete -> { - musicModel.deletePlaylist(playlist) - } - else -> { - error("Unexpected menu item selected") - } - } - } - } - - private fun openMusicMenuImpl( - anchor: View, - @MenuRes menuRes: Int, - onMenuItemClick: (MenuItem) -> Unit - ) { openMenu(anchor, menuRes) { - setOnMenuItemClickListener { item -> - onMenuItemClick(item) - true + val playable = playlist.songs.isNotEmpty() + menu.findItem(R.id.action_play).isEnabled = playable + menu.findItem(R.id.action_shuffle).isEnabled = playable + menu.findItem(R.id.action_play_next).isEnabled = playable + menu.findItem(R.id.action_queue_add).isEnabled = playable + menu.findItem(R.id.action_share).isEnabled = playable + + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_play -> { + playbackModel.play(playlist) + true + } + R.id.action_shuffle -> { + playbackModel.shuffle(playlist) + true + } + R.id.action_play_next -> { + playbackModel.playNext(playlist) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(playlist) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_rename -> { + musicModel.renamePlaylist(playlist) + true + } + R.id.action_delete -> { + musicModel.deletePlaylist(playlist) + true + } + R.id.action_share -> { + requireContext().share(playlist) + true + } + else -> { + logW("Unexpected menu item selected") + false + } + } } } } @@ -295,6 +369,8 @@ abstract class ListFragment : return } + logD("Opening popup menu menu") + currentMenu = PopupMenu(requireContext(), anchor).apply { inflate(menuRes) diff --git a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt index 5002e60cf..8a7203182 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -22,8 +22,13 @@ import androidx.annotation.IdRes import kotlin.math.max import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Sort.Mode -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt index b9d77b0f8..977c367c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt @@ -20,10 +20,12 @@ package org.oxycblt.auxio.list.adapter import android.os.Handler import android.os.Looper -import androidx.recyclerview.widget.* +import androidx.recyclerview.widget.AdapterListUpdateCallback +import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import java.util.concurrent.Executor +import org.oxycblt.auxio.util.logD /** * A variant of ListDiffer with more flexible updates. @@ -45,15 +47,18 @@ abstract class FlexibleListAdapter( /** * Update the adapter with new data. * - * @param newData The new list of data to update with. + * @param newList The new list of data to update with. * @param instructions The [UpdateInstructions] to visually update the list with. * @param callback Called when the update is completed. May be done asynchronously. */ fun update( - newData: List, + newList: List, instructions: UpdateInstructions?, callback: (() -> Unit)? = null - ) = differ.update(newData, instructions, callback) + ) { + logD("Updating list to ${newList.size} items with $instructions") + differ.update(newList, instructions, callback) + } } /** @@ -164,6 +169,7 @@ private class FlexibleListDiffer( ) { // fast simple remove all if (newList.isEmpty()) { + logD("Short-circuiting diff to remove all") val countRemoved = oldList.size currentList = emptyList() // notify last, after list is updated @@ -174,6 +180,7 @@ private class FlexibleListDiffer( // fast simple first insert if (oldList.isEmpty()) { + logD("Short-circuiting diff to insert all") currentList = newList // notify last, after list is updated updateCallback.onInserted(0, newList.size) @@ -232,8 +239,10 @@ private class FlexibleListDiffer( throw AssertionError() } }) + mainThreadExecutor.execute { if (maxScheduledGeneration == runGeneration) { + logD("Applying calculated diff") currentList = newList result.dispatchUpdatesTo(updateCallback) callback?.invoke() diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt index 67fccceac..1beddc8ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt @@ -22,6 +22,7 @@ import android.view.View import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW /** * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item. @@ -58,6 +59,8 @@ abstract class PlayingIndicatorAdapter( * @param isPlaying Whether playback is ongoing or paused. */ fun setPlaying(item: T?, isPlaying: Boolean) { + logD("Updating playing item [old: $currentItem new: $item]") + var updatedItem = false if (currentItem != item) { val oldItem = currentItem @@ -69,7 +72,7 @@ abstract class PlayingIndicatorAdapter( if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { - logD("oldItem was not in adapter data") + logW("oldItem was not in adapter data") } } @@ -79,7 +82,7 @@ abstract class PlayingIndicatorAdapter( if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { - logD("newItem was not in adapter data") + logW("newItem was not in adapter data") } } @@ -97,7 +100,7 @@ abstract class PlayingIndicatorAdapter( if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { - logD("newItem was not in adapter data") + logW("newItem was not in adapter data") } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt index 641e8b2b3..9339f78fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt @@ -22,6 +22,7 @@ import android.view.View import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.util.logD /** * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of @@ -54,6 +55,7 @@ abstract class SelectionIndicatorAdapter( // Nothing to do. return } + logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}") selectedItems = newSelectedItems for (i in currentList.indices) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt index 96ef1ffcd..9fc255ce1 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt @@ -29,6 +29,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.divider.MaterialDivider import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.recycler.DialogRecyclerView.ViewHolder import org.oxycblt.auxio.util.getDimenPixels /** diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt index ea5629e78..28112ca61 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -26,6 +26,7 @@ import androidx.core.view.isInvisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getInteger import org.oxycblt.auxio.util.logD @@ -67,7 +68,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { // this is only done once when the item is initially picked up. // TODO: I think this is possible to improve with a raw ValueAnimator. if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { - logD("Lifting item") + logD("Lifting ViewHolder") val bg = holder.background val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) @@ -109,7 +110,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { // This function can be called multiple times, so only start the animation when the view's // translationZ is already non-zero. if (holder.root.translationZ != 0f) { - logD("Dropping item") + logD("Lifting ViewHolder") val bg = holder.background val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) @@ -136,7 +137,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { // Long-press events are too buggy, only allow dragging with the handle. final override fun isLongPressDragEnabled() = false - /** Required [RecyclerView.ViewHolder] implementation that exposes the following. */ + /** Required [RecyclerView.ViewHolder] implementation that exposes required fields */ interface ViewHolder { /** Whether this [ViewHolder] can be moved right now. */ val enabled: Boolean diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 0c9962996..c829248e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -31,11 +31,16 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.areNamesTheSame +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater -import org.oxycblt.auxio.util.logD /** * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance. @@ -59,7 +64,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -109,7 +114,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -169,7 +174,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -226,7 +231,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -283,7 +288,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.root.isSelected = isActive - binding.parentImage.isPlaying = isPlaying + binding.parentImage.setPlaying(isPlaying) } override fun updateSelectionIndicator(isSelected: Boolean) { @@ -325,7 +330,6 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB * @param basicHeader The new [BasicHeader] to bind. */ fun bind(basicHeader: BasicHeader) { - logD(binding.context.getString(basicHeader.titleRes)) binding.title.text = binding.context.getString(basicHeader.titleRes) } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt index cb7bce063..88bdba6d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment +import org.oxycblt.auxio.util.share import org.oxycblt.auxio.util.showToast /** @@ -79,6 +80,10 @@ abstract class SelectionFragment : playbackModel.shuffle(selectionModel.take()) true } + R.id.action_selection_share -> { + requireContext().share(selectionModel.take()) + true + } else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 5329151ad..a5cfd776e 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -23,7 +23,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD /** * A [ViewModel] that manages the current selection. @@ -76,10 +85,19 @@ constructor( * @param music The [Music] item to select. */ fun select(music: Music) { + if (music is MusicParent && music.songs.isEmpty()) { + logD("Cannot select empty parent, ignoring operation") + return + } + val selected = _selected.value.toMutableList() if (!selected.remove(music)) { + logD("Adding $music to selection") selected.add(music) + } else { + logD("Removed $music from selection") } + _selected.value = selected } @@ -88,8 +106,9 @@ constructor( * * @return A list of [Song]s collated from each item selected. */ - fun take() = - _selected.value + fun take(): List { + logD("Taking selection") + return _selected.value .flatMap { when (it) { is Song -> listOf(it) @@ -99,12 +118,16 @@ constructor( is Playlist -> it.songs } } - .also { drop() } + .also { _selected.value = listOf() } + } /** * Clear the current selection. * * @return true if the prior selection was non-empty, false otherwise. */ - fun drop() = _selected.value.isNotEmpty().also { _selected.value = listOf() } + fun drop(): Boolean { + logD("Dropping selection [empty=${_selected.value.isEmpty()}]") + return _selected.value.isNotEmpty().also { _selected.value = listOf() } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index bcf2fb53e..a9783cfae 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -314,21 +314,23 @@ interface Album : MusicParent { */ interface Artist : MusicParent { /** - * All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist - * will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus - * included in this list. + * All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums]. + * Note that any [Song] credited to this artist will have it's [Album] considered to be + * "indirectly" linked to this [Artist], and thus included in this list. */ val albums: List + + /** Albums directly credited to this [Artist] via a "Album Artist" tag. */ + val explicitAlbums: List + + /** Albums indirectly credited to this [Artist] via an "Artist" tag. */ + val implicitAlbums: List + /** * The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no * songs. */ val durationMs: Long? - /** - * Whether this artist is considered a "collaborator", i.e it is not directly credited on any - * [Album]. - */ - val isCollaborator: Boolean /** The [Genre]s of this artist. */ val genres: List } @@ -339,8 +341,6 @@ interface Artist : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ interface Genre : MusicParent { - /** The albums indirectly linked to by the [Song]s of this [Genre]. */ - val albums: List /** The artists indirectly linked to by the [Artist]s of this [Genre]. */ val artists: List /** The total duration of the songs in this genre, in milliseconds. */ @@ -353,8 +353,6 @@ interface Genre : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ interface Playlist : MusicParent { - /** The albums indirectly linked to by the [Song]s of this [Playlist]. */ - val albums: List /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 6fa0b4f79..8d890b218 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -21,12 +21,18 @@ package org.oxycblt.auxio.music import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat -import java.util.* +import java.util.LinkedList import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.RawSong @@ -45,6 +51,9 @@ import org.oxycblt.auxio.util.logW * music (loading) can be reacted to with [UpdateListener] and [IndexingListener]. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Switch listeners to set when you can confirm there are no order-dependent listener + * configurations */ interface MusicRepository { /** The current music information found on the device. */ @@ -230,24 +239,32 @@ constructor( @Synchronized override fun addUpdateListener(listener: MusicRepository.UpdateListener) { + logD("Adding $listener to update listeners") updateListeners.add(listener) listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true)) } @Synchronized override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - updateListeners.remove(listener) + logD("Removing $listener to update listeners") + if (!updateListeners.remove(listener)) { + logW("Update listener $listener was not added prior, cannot remove") + } } @Synchronized override fun addIndexingListener(listener: MusicRepository.IndexingListener) { + logD("Adding $listener to indexing listeners") indexingListeners.add(listener) listener.onIndexingStateChanged() } @Synchronized override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - indexingListeners.remove(listener) + logD("Removing $listener from indexing listeners") + if (!indexingListeners.remove(listener)) { + logW("Indexing listener $listener was not added prior, cannot remove") + } } @Synchronized @@ -256,6 +273,7 @@ constructor( logW("Worker is already registered") return } + logD("Registering worker $worker") indexingWorker = worker if (indexingState == null) { worker.requestIndex(true) @@ -268,6 +286,7 @@ constructor( logW("Given worker did not match current worker") return } + logD("Unregistering worker $worker") indexingWorker = null currentIndexingState = null } @@ -279,44 +298,42 @@ constructor( override suspend fun createPlaylist(name: String, songs: List) { val userLibrary = synchronized(this) { userLibrary ?: return } + logD("Creating playlist $name with ${songs.size} songs") userLibrary.createPlaylist(name, songs) - notifyUserLibraryChange() + emitLibraryChange(device = false, user = true) } override suspend fun renamePlaylist(playlist: Playlist, name: String) { val userLibrary = synchronized(this) { userLibrary ?: return } + logD("Renaming $playlist to $name") userLibrary.renamePlaylist(playlist, name) - notifyUserLibraryChange() + emitLibraryChange(device = false, user = true) } override suspend fun deletePlaylist(playlist: Playlist) { val userLibrary = synchronized(this) { userLibrary ?: return } + logD("Deleting $playlist") userLibrary.deletePlaylist(playlist) - notifyUserLibraryChange() + emitLibraryChange(device = false, user = true) } override suspend fun addToPlaylist(songs: List, playlist: Playlist) { val userLibrary = synchronized(this) { userLibrary ?: return } + logD("Adding ${songs.size} songs to $playlist") userLibrary.addToPlaylist(playlist, songs) - notifyUserLibraryChange() + emitLibraryChange(device = false, user = true) } override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val userLibrary = synchronized(this) { userLibrary ?: return } + logD("Rewriting $playlist with ${songs.size} songs") userLibrary.rewritePlaylist(playlist, songs) - notifyUserLibraryChange() - } - - @Synchronized - private fun notifyUserLibraryChange() { - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + emitLibraryChange(device = false, user = true) } @Synchronized override fun requestIndex(withCache: Boolean) { + logD("Requesting index operation [cache=$withCache]") indexingWorker?.requestIndex(withCache) } @@ -343,7 +360,7 @@ constructor( private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { - logE("Permission check failed") + logE("Permissions were not granted") // No permissions, signal that we can't do anything. throw NoAudioPermissionException() } @@ -353,14 +370,16 @@ constructor( emitLoading(IndexingProgress.Indeterminate) // Do the initial query of the cache and media databases in parallel. - logD("Starting queries") + logD("Starting MediaStore query") val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() } val cache = if (withCache) { + logD("Reading cache") cacheRepository.readCache() } else { null } + logD("Awaiting MediaStore query") val query = mediaStoreQueryJob.await().getOrThrow() // Now start processing the queried song information in parallel. Songs that can't be @@ -369,11 +388,13 @@ constructor( logD("Starting song discovery") val completeSongs = Channel(Channel.UNLIMITED) val incompleteSongs = Channel(Channel.UNLIMITED) + logD("Started MediaStore discovery") val mediaStoreJob = worker.scope.tryAsync { mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) incompleteSongs.close() } + logD("Started ExoPlayer discovery") val metadataJob = worker.scope.tryAsync { tagExtractor.consume(incompleteSongs, completeSongs) @@ -386,7 +407,8 @@ constructor( rawSongs.add(rawSong) emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } - // These should be no-ops + logD("Awaiting discovery completion") + // These should be no-ops, but we need the error state to see if we should keep going. mediaStoreJob.await().getOrThrow() metadataJob.await().getOrThrow() @@ -401,25 +423,47 @@ constructor( // TODO: Indicate playlist state in loading process? emitLoading(IndexingProgress.Indeterminate) val deviceLibraryChannel = Channel() + logD("Starting DeviceLibrary creation") val deviceLibraryJob = - worker.scope.tryAsync(Dispatchers.Main) { + worker.scope.tryAsync(Dispatchers.Default) { deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } } + logD("Starting UserLibrary creation") val userLibraryJob = worker.scope.tryAsync { userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() } } if (cache == null || cache.invalidated) { + logD("Writing cache [why=${cache?.invalidated}]") cacheRepository.writeCache(rawSongs) } + logD("Awaiting library creation") val deviceLibrary = deviceLibraryJob.await().getOrThrow() val userLibrary = userLibraryJob.await().getOrThrow() - withContext(Dispatchers.Main) { - emitComplete(null) - emitData(deviceLibrary, userLibrary) + + logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]") + emitComplete(null) + + // Comparing the library instances is obscenely expensive, do it within the library + val deviceLibraryChanged = this.deviceLibrary != deviceLibrary + val userLibraryChanged = this.userLibrary != userLibrary + if (!deviceLibraryChanged && !userLibraryChanged) { + logD("Library has not changed, skipping update") + return } + + synchronized(this) { + this.deviceLibrary = deviceLibrary + this.userLibrary = userLibrary + } + + emitLibraryChange(deviceLibraryChanged, userLibraryChanged) } + /** + * An extension of [async] that forces the outcome to a [Result] to allow exceptions to bubble + * upwards instead of crashing the entire app. + */ private inline fun CoroutineScope.tryAsync( context: CoroutineContext = EmptyCoroutineContext, crossinline block: suspend () -> R @@ -447,6 +491,7 @@ constructor( synchronized(this) { previousCompletedState = IndexingState.Completed(error) currentIndexingState = null + logD("Dispatching completion state [error=$error]") for (listener in indexingListeners) { listener.onIndexingStateChanged() } @@ -454,14 +499,9 @@ constructor( } @Synchronized - private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) { - val deviceLibraryChanged = this.deviceLibrary != deviceLibrary - val userLibraryChanged = this.userLibrary != userLibrary - if (!deviceLibraryChanged && !userLibraryChanged) return - - this.deviceLibrary = deviceLibrary - this.userLibrary = userLibrary - val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged) + private fun emitLibraryChange(device: Boolean, user: Boolean) { + val changes = MusicRepository.Changes(device, user) + logD("Dispatching library change [changes=$changes]") for (listener in updateListeners) { listener.onMusicChanges(changes) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 48b180388..4274ef4e8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD /** * User configuration specific to music system. @@ -231,8 +232,14 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context getString(R.string.set_key_music_dirs), getString(R.string.set_key_music_dirs_include), getString(R.string.set_key_separators), - getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged() - getString(R.string.set_key_observing) -> listener.onObservingChanged() + getString(R.string.set_key_auto_sort_names) -> { + logD("Dispatching indexing setting change for $key") + listener.onIndexingSettingChanged() + } + getString(R.string.set_key_observing) -> { + logD("Dispatching observing setting change") + listener.onObservingChanged() + } } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index d207bd135..6390929b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent +import org.oxycblt.auxio.util.logD /** * A [ViewModel] providing data specific to the music loading process. @@ -89,6 +90,7 @@ constructor( deviceLibrary.artists.size, deviceLibrary.genres.size, deviceLibrary.songs.sumOf { it.durationMs }) + logD("Updated statistics: ${_statistics.value}") } override fun onIndexingStateChanged() { @@ -97,11 +99,13 @@ constructor( /** Requests that the music library should be re-loaded while leveraging the cache. */ fun refresh() { + logD("Refreshing library") musicRepository.requestIndex(true) } /** Requests that the music library be re-loaded without the cache. */ fun rescan() { + logD("Rescanning library") musicRepository.requestIndex(false) } @@ -113,8 +117,10 @@ constructor( */ fun createPlaylist(name: String? = null, songs: List = listOf()) { if (name != null) { + logD("Creating $name with ${songs.size} songs]") viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } } else { + logD("Launching creation dialog for ${songs.size} songs") _newPlaylistSongs.put(songs) } } @@ -127,8 +133,10 @@ constructor( */ fun renamePlaylist(playlist: Playlist, name: String? = null) { if (name != null) { + logD("Renaming $playlist to $name") viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } } else { + logD("Launching rename dialog for $playlist") _playlistToRename.put(playlist) } } @@ -142,8 +150,10 @@ constructor( */ fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { if (rude) { + logD("Deleting $playlist") viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } } else { + logD("Launching deletion dialog for $playlist") _playlistToDelete.put(playlist) } } @@ -155,6 +165,7 @@ constructor( * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. */ fun addToPlaylist(song: Song, playlist: Playlist? = null) { + logD("Adding $song to playlist") addToPlaylist(listOf(song), playlist) } @@ -165,6 +176,7 @@ constructor( * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. */ fun addToPlaylist(album: Album, playlist: Playlist? = null) { + logD("Adding $album to playlist") addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist) } @@ -175,6 +187,7 @@ constructor( * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. */ fun addToPlaylist(artist: Artist, playlist: Playlist? = null) { + logD("Adding $artist to playlist") addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist) } @@ -185,6 +198,7 @@ constructor( * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. */ fun addToPlaylist(genre: Genre, playlist: Playlist? = null) { + logD("Adding $genre to playlist") addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist) } @@ -196,8 +210,10 @@ constructor( */ fun addToPlaylist(songs: List, playlist: Playlist? = null) { if (playlist != null) { + logD("Adding ${songs.size} songs to $playlist") viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } } else { + logD("Launching addition dialog for songs=${songs.size}") _songsToAdd.put(songs) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt index 967d53d68..0b91c65b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt @@ -20,7 +20,8 @@ package org.oxycblt.auxio.music.cache import javax.inject.Inject import org.oxycblt.auxio.music.device.RawSong -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE /** * A repository allowing access to cached metadata obtained in prior music loading operations. @@ -49,7 +50,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached try { // Faster to load the whole database into memory than do a query on each // populate call. - CacheImpl(cachedSongsDao.readSongs()) + val songs = cachedSongsDao.readSongs() + logD("Successfully read ${songs.size} songs from cache") + CacheImpl(songs) } catch (e: Exception) { logE("Unable to load cache database.") logE(e.stackTraceToString()) @@ -60,7 +63,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached try { // Still write out whatever data was extracted. cachedSongsDao.nukeSongs() + logD("Successfully deleted old cache") cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw)) + logD("Successfully wrote ${rawSongs.size} songs to cache") } catch (e: Exception) { logE("Unable to save cache database.") logE(e.stackTraceToString()) @@ -96,7 +101,6 @@ private class CacheImpl(cachedSongs: List) : Cache { override var invalidated = false override fun populate(rawSong: RawSong): Boolean { - // 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 // exist, but to safeguard against possible OEM-specific timestamp incoherence, we diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index af42d85ab..814c15818 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -23,7 +23,13 @@ import android.net.Uri import android.provider.OpenableColumns import javax.inject.Inject import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery import org.oxycblt.auxio.util.logD @@ -128,20 +134,11 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } } private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } } - override fun equals(other: Any?) = - other is DeviceLibrary && - other.songs == songs && - other.albums == albums && - other.artists == artists && - other.genres == genres - - override fun hashCode(): Int { - var hashCode = songs.hashCode() - hashCode = hashCode * 31 + albums.hashCode() - hashCode = hashCode * 31 + artists.hashCode() - hashCode = hashCode * 31 + genres.hashCode() - return hashCode - } + // All other music is built from songs, so comparison only needs to check songs. + override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs + override fun hashCode() = songs.hashCode() + override fun toString() = + "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, artists=${artists.size}, genres=${genres.size})" override fun findSong(uid: Music.UID) = songUidMap[uid] override fun findAlbum(uid: Music.UID) = albumUidMap[uid] @@ -160,100 +157,69 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings songs.find { it.path.name == displayName && it.size == size } } - /** - * Build a list [SongImpl]s from the given [RawSong]. - * - * @param rawSongs The [RawSong]s to build the [SongImpl]s from. - * @param settings [MusicSettings] to obtain user parsing configuration. - * @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for - * grouping. - */ - private fun buildSongs(rawSongs: List, settings: MusicSettings) = - Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - .songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid }) + private fun buildSongs(rawSongs: List, settings: MusicSettings): List { + val start = System.currentTimeMillis() + val songs = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + .songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid }) + logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") + return songs + } - /** - * Build a list of [Album]s from the given [Song]s. - * - * @param songs The [Song]s to build [Album]s from. These will be linked with their respective - * [Album]s when created. - * @param settings [MusicSettings] to obtain user parsing configuration. - * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked - * with parent [Artist] instances in order to be usable. - */ private fun buildAlbums(songs: List, settings: MusicSettings): List { + val start = System.currentTimeMillis() // Group songs by their singular raw album, then map the raw instances and their // grouped songs to Album values. Album.Raw will handle the actual grouping rules. - val songsByAlbum = songs.groupBy { it.rawAlbum } - val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) } - logD("Successfully built ${albums.size} albums") + val songsByAlbum = songs.groupBy { it.rawAlbum.key } + val albums = songsByAlbum.map { AlbumImpl(it.key.value, settings, it.value) } + logD("Successfully built ${albums.size} albums in ${System.currentTimeMillis() - start}ms") return albums } - /** - * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as - * they group into [Artist] instances much differently, with [Song]s being grouped primarily by - * artist names, and [Album]s being grouped primarily by album artist names. - * - * @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of - * one or more [Artist] instances. These will be linked with their respective [Artist]s when - * created. - * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of - * one or more [Artist] instances. These will be linked with their respective [Artist]s when - * created. - * @param settings [MusicSettings] to obtain user parsing configuration. - * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings - * of [Song]s and [Album]s. - */ private fun buildArtists( songs: List, albums: List, settings: MusicSettings ): List { + val start = System.currentTimeMillis() // Add every raw artist credited to each Song/Album to the grouping. This way, // different multi-artist combinations are not treated as different artists. - val musicByArtist = mutableMapOf>() + // Songs and albums are grouped by artist and album artist respectively. + val musicByArtist = mutableMapOf>() for (song in songs) { for (rawArtist in song.rawArtists) { - musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) + musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(song) } } for (album in albums) { for (rawArtist in album.rawArtists) { - musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) + musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(album) } } // Convert the combined mapping into artist instances. - val artists = musicByArtist.map { ArtistImpl(it.key, settings, it.value) } - logD("Successfully built ${artists.size} artists") + val artists = musicByArtist.map { ArtistImpl(it.key.value, settings, it.value) } + logD( + "Successfully built ${artists.size} artists in ${System.currentTimeMillis() - start}ms") return artists } - /** - * Group up [Song]s into [Genre] instances. - * - * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of - * one or more [Genre] instances. These will be linked with their respective [Genre]s when - * created. - * @param settings [MusicSettings] to obtain user parsing configuration. - * @return A non-empty list of [Genre]s. - */ private fun buildGenres(songs: List, settings: MusicSettings): List { + val start = System.currentTimeMillis() // Add every raw genre credited to each Song to the grouping. This way, // different multi-genre combinations are not treated as different genres. - val songsByGenre = mutableMapOf>() + val songsByGenre = mutableMapOf>() for (song in songs) { for (rawGenre in song.rawGenres) { - songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) + songsByGenre.getOrPut(rawGenre.key) { mutableListOf() }.add(song) } } // Convert the mapping into genre instances. - val genres = songsByGenre.map { GenreImpl(it.key, settings, it.value) } - logD("Successfully built ${genres.size} genres") + val genres = songsByGenre.map { GenreImpl(it.key.value, settings, it.value) } + logD("Successfully built ${genres.size} genres in ${System.currentTimeMillis() - start}ms") return genres } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt index 41b69a498..85e8e511e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt @@ -26,5 +26,5 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface DeviceModule { - @Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory + @Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index c98049d68..e3ce99928 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -20,13 +20,21 @@ package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.fs.toCoverUri -import org.oxycblt.auxio.music.info.* import org.oxycblt.auxio.music.info.Date +import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.Name +import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.parseId3GenreNames import org.oxycblt.auxio.music.metadata.parseMultiValue import org.oxycblt.auxio.util.nonZeroOrNull @@ -85,9 +93,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son override val album: Album get() = unlikelyToBeNull(_album) - override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode() + private val hashCode = 31 * uid.hashCode() + rawSong.hashCode() + + override fun hashCode() = hashCode override fun equals(other: Any?) = other is SongImpl && uid == other.uid && rawSong == other.rawSong + override fun toString() = "Song(uid=$uid, name=$name)" private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings) private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings) @@ -237,44 +248,61 @@ class AlbumImpl( update(rawAlbum.rawArtists.map { it.name }) } override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings) - - override val dates = Date.Range.from(songs.mapNotNull { it.date }) + override val dates: Date.Range? override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val coverUri = rawAlbum.mediaStoreId.toCoverUri() override val durationMs: Long override val dateAdded: Long - override fun hashCode(): Int { - var hashCode = uid.hashCode() - hashCode = 31 * hashCode + rawAlbum.hashCode() - hashCode = 31 * hashCode + songs.hashCode() - return hashCode - } - - override fun equals(other: Any?) = - other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs - private val _artists = mutableListOf() override val artists: List get() = _artists + private var hashCode = uid.hashCode() + init { var totalDuration: Long = 0 + var minDate: Date? = null + var maxDate: Date? = null var earliestDateAdded: Long = Long.MAX_VALUE // Do linking and value generation in the same loop for efficiency. for (song in songs) { song.link(this) + + if (song.date != null) { + val min = minDate + if (min == null || song.date < min) { + minDate = song.date + } + + val max = maxDate + if (max == null || song.date > max) { + maxDate = song.date + } + } + if (song.dateAdded < earliestDateAdded) { earliestDateAdded = song.dateAdded } totalDuration += song.durationMs } + val min = minDate + val max = maxDate + dates = if (min != null && max != null) Date.Range(min, max) else null durationMs = totalDuration dateAdded = earliestDateAdded + + hashCode = 31 * hashCode + rawAlbum.hashCode() + hashCode = 31 * hashCode + songs.hashCode() } + override fun hashCode() = hashCode + override fun equals(other: Any?) = + other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs + override fun toString() = "Album(uid=$uid, name=$name)" + /** * The [RawArtist] instances collated by the [Album]. The album artists of the song take * priority, followed by the artists. If there are no artists, this field will be a single @@ -336,17 +364,48 @@ class ArtistImpl( override val songs: List override val albums: List + override val explicitAlbums: List + override val implicitAlbums: List override val durationMs: Long? - override val isCollaborator: Boolean + + override lateinit var genres: List + + private var hashCode = uid.hashCode() + + init { + val distinctSongs = mutableSetOf() + val albumMap = mutableMapOf() + + for (music in songAlbums) { + when (music) { + is SongImpl -> { + music.link(this) + distinctSongs.add(music) + if (albumMap[music.album] == null) { + albumMap[music.album] = false + } + } + is AlbumImpl -> { + music.link(this) + albumMap[music] = true + } + else -> error("Unexpected input music ${music::class.simpleName}") + } + } + + songs = distinctSongs.toList() + albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(albumMap.keys) + explicitAlbums = albums.filter { unlikelyToBeNull(albumMap[it]) } + implicitAlbums = albums.filterNot { unlikelyToBeNull(albumMap[it]) } + durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull() + + hashCode = 31 * hashCode + rawArtist.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + } // Note: Append song contents to MusicParent equality so that artists with // the same UID but different songs are not equal. - override fun hashCode(): Int { - var hashCode = uid.hashCode() - hashCode = 31 * hashCode + rawArtist.hashCode() - hashCode = 31 * hashCode + songs.hashCode() - return hashCode - } + override fun hashCode() = hashCode override fun equals(other: Any?) = other is ArtistImpl && @@ -354,35 +413,7 @@ class ArtistImpl( rawArtist == other.rawArtist && songs == other.songs - override lateinit var genres: List - - init { - val distinctSongs = mutableSetOf() - val distinctAlbums = mutableSetOf() - - var noAlbums = true - - for (music in songAlbums) { - when (music) { - is SongImpl -> { - music.link(this) - distinctSongs.add(music) - distinctAlbums.add(music.album) - } - is AlbumImpl -> { - music.link(this) - distinctAlbums.add(music) - noAlbums = false - } - else -> error("Unexpected input music ${music::class.simpleName}") - } - } - - songs = distinctSongs.toList() - albums = distinctAlbums.toList() - durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull() - isCollaborator = noAlbums - } + override fun toString() = "Artist(uid=$uid, name=$name)" /** * Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist] @@ -393,7 +424,8 @@ class ArtistImpl( * [RawArtist] will be within the list. * @return The index of the [Artist]'s [RawArtist] within the list. */ - fun getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(rawArtist) + fun getOriginalPositionIn(rawArtists: List) = + rawArtists.indexOfFirst { it.key == rawArtist.key } /** * Perform final validation and organization on this instance. @@ -427,19 +459,10 @@ class GenreImpl( rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) } ?: Name.Unknown(R.string.def_genre) - override val albums: List override val artists: List override val durationMs: Long - override fun hashCode(): Int { - var hashCode = uid.hashCode() - hashCode = 31 * hashCode + rawGenre.hashCode() - hashCode = 31 * hashCode + songs.hashCode() - return hashCode - } - - override fun equals(other: Any?) = - other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs + private var hashCode = uid.hashCode() init { val distinctAlbums = mutableSetOf() @@ -453,14 +476,19 @@ class GenreImpl( totalDuration += song.durationMs } - albums = - Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - .albums(distinctAlbums) - .sortedByDescending { album -> album.songs.count { it.genres.contains(this) } } artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists) durationMs = totalDuration + hashCode = 31 * hashCode + rawGenre.hashCode() + hashCode = 31 * hashCode + songs.hashCode() } + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs + + override fun toString() = "Genre(uid=$uid, name=$name)" + /** * Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list. * This can be used to create a consistent ordering within child [Genre] lists based on the @@ -470,7 +498,8 @@ class GenreImpl( * [RawGenre] will be within the list. * @return The index of the [Genre]'s [RawGenre] within the list. */ - fun getOriginalPositionIn(rawGenres: List) = rawGenres.indexOf(rawGenre) + fun getOriginalPositionIn(rawGenres: List) = + rawGenres.indexOfFirst { it.key == rawGenre.key } /** * Perform final validation and organization on this instance. diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 23b02b2f1..2a198c687 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -19,7 +19,9 @@ package org.oxycblt.auxio.music.device import java.util.UUID -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.ReleaseType @@ -111,28 +113,35 @@ data class RawAlbum( /** @see RawArtist.name */ val rawArtists: List ) { - // Albums are grouped as follows: - // - If we have a MusicBrainz ID, only group by it. This allows different Albums with the - // same name to be differentiated, which is common in large libraries. - // - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase - // artist name. This allows for case-insensitive artist/album grouping, which can be common - // for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein"). + val key = Key(this) - // Cache the hash-code for HashMap efficiency. - private val hashCode = - musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode()) + /** Exposed information that denotes [RawAlbum] uniqueness. */ + data class Key(val value: RawAlbum) { + // Albums are grouped as follows: + // - If we have a MusicBrainz ID, only group by it. This allows different Albums with the + // same name to be differentiated, which is common in large libraries. + // - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase + // artist name. This allows for case-insensitive artist/album grouping, which can be common + // for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein"). - override fun hashCode() = hashCode + // Cache the hash-code for HashMap efficiency. + private val hashCode = + value.musicBrainzId?.hashCode() + ?: (31 * value.name.lowercase().hashCode() + value.rawArtists.hashCode()) - override fun equals(other: Any?) = - other is RawAlbum && - when { - musicBrainzId != null && other.musicBrainzId != null -> - musicBrainzId == other.musicBrainzId - musicBrainzId == null && other.musicBrainzId == null -> - name.equals(other.name, true) && rawArtists == other.rawArtists - else -> false - } + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is Key && + when { + value.musicBrainzId != null && other.value.musicBrainzId != null -> + value.musicBrainzId == other.value.musicBrainzId + value.musicBrainzId == null && other.value.musicBrainzId == null -> + other.value.name.equals(other.value.name, true) && + other.value.rawArtists == other.value.rawArtists + else -> false + } + } } /** @@ -149,33 +158,42 @@ data class RawArtist( /** @see Music.name */ val sortName: String? = null ) { - // Artists are grouped as follows: - // - If we have a MusicBrainz ID, only group by it. This allows different Artists with the - // same name to be differentiated, which is common in large libraries. - // - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist - // grouping to be case-insensitive. + val key = Key(this) - // Cache the hashCode for HashMap efficiency. - private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode() + /** + * Allows [RawArtist]s to be compared by "fundamental" information that is unlikely to change on + * an item-by-item + */ + data class Key(val value: RawArtist) { + // Artists are grouped as follows: + // - If we have a MusicBrainz ID, only group by it. This allows different Artists with the + // same name to be differentiated, which is common in large libraries. + // - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist + // grouping to be case-insensitive. - // Compare names and MusicBrainz IDs in order to differentiate artists with the - // same name in large libraries. + // Cache the hashCode for HashMap efficiency. + private val hashCode = value.musicBrainzId?.hashCode() ?: value.name?.lowercase().hashCode() - override fun hashCode() = hashCode + // Compare names and MusicBrainz IDs in order to differentiate artists with the + // same name in large libraries. - override fun equals(other: Any?) = - other is RawArtist && - when { - musicBrainzId != null && other.musicBrainzId != null -> - musicBrainzId == other.musicBrainzId - musicBrainzId == null && other.musicBrainzId == null -> - when { - name != null && other.name != null -> name.equals(other.name, true) - name == null && other.name == null -> true - else -> false - } - else -> false - } + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is Key && + when { + value.musicBrainzId != null && other.value.musicBrainzId != null -> + value.musicBrainzId == other.value.musicBrainzId + value.musicBrainzId == null && other.value.musicBrainzId == null -> + when { + value.name != null && other.value.name != null -> + value.name.equals(other.value.name, true) + value.name == null && other.value.name == null -> true + else -> false + } + else -> false + } + } } /** @@ -187,20 +205,24 @@ data class RawGenre( /** @see Music.name */ val name: String? = null ) { + val key = Key(this) - // Cache the hashCode for HashMap efficiency. - private val hashCode = name?.lowercase().hashCode() + data class Key(val value: RawGenre) { + // Cache the hashCode for HashMap efficiency. + private val hashCode = value.name?.lowercase().hashCode() - // Only group by the lowercase genre name. This allows Genre grouping to be - // case-insensitive, which may be helpful in some libraries with different ways of - // formatting genres. - override fun hashCode() = hashCode + // Only group by the lowercase genre name. This allows Genre grouping to be + // case-insensitive, which may be helpful in some libraries with different ways of + // formatting genres. + override fun hashCode() = hashCode - override fun equals(other: Any?) = - other is RawGenre && - when { - name != null && other.name != null -> name.equals(other.name, true) - name == null && other.name == null -> true - else -> false - } + override fun equals(other: Any?) = + other is Key && + when { + value.name != null && other.value.name != null -> + value.name.equals(other.value.name, true) + value.name == null && other.value.name == null -> true + else -> false + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt index 5913c2b8c..5e0799d72 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt @@ -25,6 +25,7 @@ import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * [RecyclerView.Adapter] that manages a list of [Directory] instances. @@ -54,10 +55,8 @@ class DirectoryAdapter(private val listener: Listener) : * @param dir The [Directory] to add. */ fun add(dir: Directory) { - if (_dirs.contains(dir)) { - return - } - + if (_dirs.contains(dir)) return + logD("Adding $dir") _dirs.add(dir) notifyItemInserted(_dirs.lastIndex) } @@ -65,9 +64,10 @@ class DirectoryAdapter(private val listener: Listener) : /** * Add a list of [Directory] instances to the end of the list. * - * @param dirs The [Directory instances to add. + * @param dirs The [Directory] instances to add. */ fun addAll(dirs: List) { + logD("Adding ${dirs.size} directories") val oldLastIndex = dirs.lastIndex _dirs.addAll(dirs) notifyItemRangeInserted(oldLastIndex, dirs.size) @@ -79,6 +79,7 @@ class DirectoryAdapter(private val listener: Listener) : * @param dir The [Directory] to remove. Must exist in the list. */ fun remove(dir: Directory) { + logD("Removing $dir") val idx = _dirs.indexOf(dir) _dirs.removeAt(idx) notifyItemRemoved(idx) @@ -86,6 +87,7 @@ class DirectoryAdapter(private val listener: Listener) : /** A Listener for [DirectoryAdapter] interactions. */ interface Listener { + /** Called when the delete button on a directory item is clicked. */ fun onRemoveDirectory(dir: Directory) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index defbb7c3f..93a777a6a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -145,6 +145,8 @@ data class MusicDirectories(val dirs: List, val shouldInclude: Boolea * @param fromFormat The mime type obtained by analyzing the file format. Null if could not be * obtained. * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Get around to simplifying this */ data class MimeType(val fromExtension: String, val fromFormat: String?) { /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 0df80983b..42e29eed2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -120,6 +120,7 @@ private abstract class BaseMediaStoreExtractor( if (dirs.dirs.isNotEmpty()) { selector += " AND " if (!dirs.shouldInclude) { + logD("Excluding directories in selector") // Without a NOT, the query will be restricted to the specified paths, resulting // in the "Include" mode. With a NOT, the specified paths will not be included, // resulting in the "Exclude" mode. @@ -144,14 +145,14 @@ private abstract class BaseMediaStoreExtractor( } // Now we can actually query MediaStore. - logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]") + logD("Starting song query [proj=${projection.toList()}, selector=$selector, args=$args]") val cursor = context.contentResolverSafe.safeQuery( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selector, args.toTypedArray()) - logD("Song query succeeded [Projected total: ${cursor.count}]") + logD("Successfully queried for ${cursor.count} songs") val genreNamesMap = mutableMapOf() @@ -186,6 +187,7 @@ private abstract class BaseMediaStoreExtractor( } } + logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore") logD("Finished initialization in ${System.currentTimeMillis() - start}ms") return wrapQuery(cursor, genreNamesMap) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt index 46e4130f0..860b3e315 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt @@ -24,6 +24,7 @@ import java.text.SimpleDateFormat import kotlin.math.max import org.oxycblt.auxio.R import org.oxycblt.auxio.util.inRangeOrNull +import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.nonZeroOrNull /** @@ -51,33 +52,30 @@ class Date private constructor(private val tokens: List) : Comparable * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will * be properly localized. */ - fun resolveDate(context: Context): String { - if (month != null) { - // Parse a date format from an ISO-ish format - val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat) - format.applyPattern("yyyy-MM") - val date = - try { - format.parse("$year-$month") - } catch (e: ParseException) { - null - } - - if (date != null) { - // Reformat as a readable month and year - format.applyPattern("MMM yyyy") - return format.format(date) - } - } - + fun resolve(context: Context) = // Unable to create fine-grained date, just format as a year. - return context.getString(R.string.fmt_number, year) + month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year) + + private fun resolveFineGrained(): String? { + // We can't directly load a date with our own + val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat) + format.applyPattern("yyyy-MM") + val date = + try { + format.parse("$year-$month") + } catch (e: ParseException) { + logE("Unable to parse fine-grained date: $e") + return null + } + + // Reformat as a readable month and year + format.applyPattern("MMM yyyy") + return format.format(date) } - override fun hashCode() = tokens.hashCode() - override fun equals(other: Any?) = other is Date && compareTo(other) == 0 - + override fun hashCode() = tokens.hashCode() + override fun toString() = StringBuilder().appendDate().toString() override fun compareTo(other: Date): Int { for (i in 0 until max(tokens.size, other.tokens.size)) { val ai = tokens.getOrNull(i) @@ -98,8 +96,6 @@ class Date private constructor(private val tokens: List) : Comparable return 0 } - override fun toString() = StringBuilder().appendDate().toString() - private fun StringBuilder.appendDate(): StringBuilder { // Construct an ISO-8601 date, dropping precision that doesn't exist. append(year.toStringFixed(4)) @@ -120,13 +116,15 @@ class Date private constructor(private val tokens: List) : Comparable * * @author Alexander Capehart */ - class Range - private constructor( + class Range( /** The earliest [Date] in the range. */ val min: Date, /** the latest [Date] in the range. May be the same as [min]. ] */ val max: Date ) : Comparable { + init { + check(min <= max) { "Min date must be <= max date" } + } /** * Resolve this instance into a human-readable date range. @@ -139,9 +137,9 @@ class Date private constructor(private val tokens: List) : Comparable fun resolveDate(context: Context) = if (min != max) { context.getString( - R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context)) + R.string.fmt_date_range, min.resolve(context), max.resolve(context)) } else { - min.resolveDate(context) + min.resolve(context) } override fun equals(other: Any?) = other is Range && min == other.min && max == other.max @@ -149,35 +147,6 @@ class Date private constructor(private val tokens: List) : Comparable override fun hashCode() = 31 * max.hashCode() + min.hashCode() override fun compareTo(other: Range) = min.compareTo(other.min) - - companion object { - /** - * Create a [Range] from the given list of [Date]s. - * - * @param dates The [Date]s to use. - * @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s, - * null is returned. - */ - fun from(dates: List): Range? { - if (dates.isEmpty()) { - // Nothing to do. - return null - } - // Simultaneously find the minimum and maximum values in the given range. - // If this list has only one item, then that one date is the minimum and maximum. - var min = dates.first() - var max = min - for (i in 1..dates.lastIndex) { - if (dates[i] < min) { - min = dates[i] - } - if (dates[i] > max) { - max = dates[i] - } - } - return Range(min, max) - } - } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index 759d52b49..52b7ab646 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -27,6 +27,7 @@ import org.oxycblt.auxio.list.Item * @param name The name of the disc group, if any. Null if not present. */ class Disc(val number: Int, val name: String?) : Item, Comparable { + // We don't want to group discs by differing subtitles, so only compare by the number override fun equals(other: Any?) = other is Disc && number == other.number override fun hashCode() = number.hashCode() override fun compareTo(other: Disc) = number.compareTo(other.number) diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index 3b7c3bfc7..838f8f5d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -174,6 +174,8 @@ private data class IntelligentKnownName(override val raw: String, override val s override val sortTokens = parseTokens(sort ?: raw) private fun parseTokens(name: String): List { + // TODO: This routine is consuming much of the song building runtime, find a way to + // optimize it val stripped = name // Remove excess punctuation from the string, as those u @@ -201,6 +203,7 @@ private data class IntelligentKnownName(override val raw: String, override val s // Separate each token into their numeric and lexicographic counterparts. if (token.first().isDigit()) { // The digit string comparison breaks with preceding zero digits, remove those + // TODO: Handle zero digits in other languages val digits = token.trimStart('0').ifEmpty { token } // Other languages have other types of digit strings, still use collation keys collationKey = COLLATOR.getCollationKey(digits) diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt index 20ac60034..c4cb00fd3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.info import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.info.ReleaseType.Album /** * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt index acea28744..6e3646b62 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt @@ -104,25 +104,23 @@ constructor(@ApplicationContext private val context: Context) : AudioProperties. null } - val resolvedMimeType = - if (song.mimeType.fromFormat != null) { - // ExoPlayer was already able to populate the format. - song.mimeType - } else { - // ExoPlayer couldn't populate the format somehow, populate it here. - val formatMimeType = - try { - format.getString(MediaFormat.KEY_MIME) - } catch (e: NullPointerException) { - logE("Unable to extract mime type field") - null - } - - MimeType(song.mimeType.fromExtension, formatMimeType) + // The song's mime type won't have a populated format field right now, try to + // extract it ourselves. + val formatMimeType = + try { + format.getString(MediaFormat.KEY_MIME) + } catch (e: NullPointerException) { + logE("Unable to extract mime type field") + null } extractor.release() - return AudioProperties(bitrate, sampleRate, resolvedMimeType) + logD("Finished extracting audio properties") + + return AudioProperties( + bitrate, + sampleRate, + MimeType(fromExtension = song.mimeType.fromExtension, fromFormat = formatMimeType)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 5fa612667..3496ea059 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -30,12 +30,15 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.logW /** * A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to * split tags with multiple values. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Replace with unsplit names dialog */ @AndroidEntryPoint class SeparatorsDialog : ViewBindingDialogFragment() { @@ -74,7 +77,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { Separators.SLASH -> binding.separatorSlash.isChecked = true Separators.PLUS -> binding.separatorPlus.isChecked = true Separators.AND -> binding.separatorAnd.isChecked = true - else -> error("Unexpected separator in settings data") + else -> logW("Unexpected separator in settings data") } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index bbc2971a0..4cca1a824 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt @@ -23,6 +23,7 @@ import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.util.logD /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the @@ -52,6 +53,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork // producing similar throughput's to other kinds of manual metadata extraction. val tagWorkerPool: Array = arrayOfNulls(TASK_CAPACITY) + logD("Beginning primary extraction loop") + for (incompleteRawSong in incompleteSongs) { spin@ while (true) { for (i in tagWorkerPool.indices) { @@ -71,6 +74,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork } } + logD("All incomplete songs exhausted, starting cleanup loop") + do { var ongoingTasks = false for (i in tagWorkerPool.indices) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt index 62f19c61b..c4f20df4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt @@ -39,6 +39,8 @@ fun List.parseMultiValue(settings: MusicSettings) = this } +// TODO: Remove the escaping checks, it's too expensive to do this for every single tag. + /** * Split a [String] by the given selector, automatically handling escaped characters that satisfy * the selector. @@ -106,7 +108,7 @@ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } * @return A list of one or more [String]s that were split up by the user-defined separators. */ private fun String.maybeParseBySeparators(settings: MusicSettings): List { - // Get the separators the user desires. If null, there's nothing to do. + if (settings.multiValueSeparators.isEmpty()) return listOf(this) return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 115462a8a..b709cb558 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -89,12 +89,8 @@ private class TagWorkerImpl( } catch (e: Exception) { logW("Unable to extract metadata for ${rawSong.name}") logW(e.stackTraceToString()) - null + return rawSong } - if (format == null) { - logD("Nothing could be extracted for ${rawSong.name}") - return rawSong - } val metadata = format.metadata if (metadata != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index 1cdb8b4db..d22f9a5e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast /** @@ -93,7 +94,7 @@ class AddToPlaylistDialog : private fun updatePendingSongs(songs: List?) { if (songs == null) { - // No songs to feasibly add to a playlist, leave. + logD("No songs to show choices for, navigating away") findNavController().navigateUp() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt index afc90c825..15d347199 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull @@ -76,7 +77,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment() private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { if (pendingPlaylist == null) { + logD("No playlist to create, leaving") findNavController().navigateUp() return } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt index 2181f4605..51a9895cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -31,6 +31,9 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.logW /** * A [ViewModel] managing the state of the playlist picker dialogs. @@ -84,6 +87,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M pendingPlaylist.preferredName, pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) } + logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}") + _currentSongsToAdd.value = _currentSongsToAdd.value?.let { pendingSongs -> pendingSongs @@ -91,6 +96,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M .ifEmpty { null } .also { refreshChoicesWith = it } } + logD("Updated songs to add: ${_currentSongsToAdd.value?.size} songs") } val chosenName = _chosenName.value @@ -102,6 +108,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M // Nothing to do. } } + logD("Updated chosen name to $chosenName") refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value } @@ -119,19 +126,34 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * @param songUids The [Music.UID]s of songs to be present in the playlist. */ fun setPendingPlaylist(context: Context, songUids: Array) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val songs = songUids.mapNotNull(deviceLibrary::findSong) - + logD("Opening ${songUids.size} songs to create a playlist from") val userLibrary = musicRepository.userLibrary ?: return - var i = 1 - while (true) { - val possibleName = context.getString(R.string.fmt_def_playlist, i) - if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) { - _currentPendingPlaylist.value = PendingPlaylist(possibleName, songs) - return + val songs = + musicRepository.deviceLibrary + ?.let { songUids.mapNotNull(it::findSong) } + ?.also(::refreshPlaylistChoices) + + val possibleName = + musicRepository.userLibrary?.let { + // Attempt to generate a unique default name for the playlist, like "Playlist 1". + var i = 1 + var possibleName: String + do { + possibleName = context.getString(R.string.fmt_def_playlist, i) + logD("Trying $possibleName as a playlist name") + ++i + } while (userLibrary.playlists.any { it.name.resolve(context) == possibleName }) + logD("$possibleName is unique, using it as the playlist name") + possibleName + } + + _currentPendingPlaylist.value = + if (possibleName != null && songs != null) { + PendingPlaylist(possibleName, songs) + } else { + logW("Given song UIDs to create were invalid") + null } - ++i - } } /** @@ -140,7 +162,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * @param playlistUid The [Music.UID]s of the [Playlist] to rename. */ fun setPlaylistToRename(playlistUid: Music.UID) { + logD("Opening playlist $playlistUid to rename") _currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + if (_currentPlaylistToDelete.value == null) { + logW("Given playlist UID to rename was invalid") + } } /** @@ -149,7 +175,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * @param playlistUid The [Music.UID] of the [Playlist] to delete. */ fun setPlaylistToDelete(playlistUid: Music.UID) { + logD("Opening playlist $playlistUid to delete") _currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + if (_currentPlaylistToDelete.value == null) { + logW("Given playlist UID to delete was invalid") + } } /** @@ -158,16 +188,25 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * @param name The new user-inputted name, or null if not present. */ fun updateChosenName(name: String?) { + logD("Updating chosen name to $name") _chosenName.value = when { - name.isNullOrEmpty() -> ChosenName.Empty - name.isBlank() -> ChosenName.Blank + name.isNullOrEmpty() -> { + logE("Chosen name is empty") + ChosenName.Empty + } + name.isBlank() -> { + logE("Chosen name is blank") + ChosenName.Blank + } else -> { val trimmed = name.trim() val userLibrary = musicRepository.userLibrary if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) { + logD("Chosen name is valid") ChosenName.Valid(trimmed) } else { + logD("Chosen name already exists in library") ChosenName.AlreadyExists(trimmed) } } @@ -180,14 +219,19 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * @param songUids The [Music.UID]s of songs to add to a playlist. */ fun setSongsToAdd(songUids: Array) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val songs = songUids.mapNotNull(deviceLibrary::findSong) - _currentSongsToAdd.value = songs - refreshPlaylistChoices(songs) + logD("Opening ${songUids.size} songs to add to a playlist") + _currentSongsToAdd.value = + musicRepository.deviceLibrary + ?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } } + ?.also(::refreshPlaylistChoices) + if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) { + logW("Given song UIDs to add were (partially) invalid") + } } private fun refreshPlaylistChoices(songs: List) { val userLibrary = musicRepository.userLibrary ?: return + logD("Refreshing playlist choices") _playlistAddChoices.value = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { val songSet = it.songs.toSet() diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt index fcc8b2538..20ed39bd5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull @@ -86,7 +87,9 @@ class RenamePlaylistDialog : ViewBindingDialogFragment ) : Playlist { override val durationMs = songs.sumOf { it.durationMs } - override val albums = - songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } + private var hashCode = uid.hashCode() + + init { + hashCode = 31 * hashCode + name.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + } + + override fun equals(other: Any?) = + other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs + override fun hashCode() = hashCode + override fun toString() = "Playlist(uid=$uid, name=$name)" /** * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. @@ -55,16 +68,6 @@ private constructor( */ inline fun edit(edits: MutableList.() -> Unit) = edit(songs.toMutableList().apply(edits)) - override fun equals(other: Any?) = - other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs - - override fun hashCode(): Int { - var hashCode = uid.hashCode() - hashCode = 31 * hashCode + name.hashCode() - hashCode = 31 * hashCode + songs.hashCode() - return hashCode - } - companion object { /** * Create a new instance with a novel UID. diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt index 96d3b5e77..27cf62554 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -18,7 +18,12 @@ package org.oxycblt.auxio.music.user -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Junction +import androidx.room.PrimaryKey +import androidx.room.Relation import org.oxycblt.auxio.music.Music /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index fc64f5918..412b14fa4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -18,10 +18,17 @@ package org.oxycblt.auxio.music.user +import java.lang.Exception import javax.inject.Inject import kotlinx.coroutines.channels.Channel -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE /** * Organized library information controlled by the user. @@ -118,7 +125,14 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus UserLibrary.Factory { override suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary { // While were waiting for the library, read our playlists out. - val rawPlaylists = playlistDao.readRawPlaylists() + val rawPlaylists = + try { + playlistDao.readRawPlaylists() + } catch (e: Exception) { + logE("Unable to read playlists: $e") + return UserLibraryImpl(playlistDao, mutableMapOf(), musicSettings) + } + logD("Successfully read ${rawPlaylists.size} playlists") val deviceLibrary = deviceLibraryChannel.receive() // Convert the database playlist information to actual usable playlists. val playlistMap = mutableMapOf() @@ -135,6 +149,10 @@ private class UserLibraryImpl( private val playlistMap: MutableMap, private val musicSettings: MusicSettings ) : MutableUserLibrary { + override fun hashCode() = playlistMap.hashCode() + override fun equals(other: Any?) = other is UserLibraryImpl && other.playlistMap == playlistMap + override fun toString() = "UserLibrary(playlists=${playlists.size})" + override val playlists: List get() = playlistMap.values.toList() @@ -143,40 +161,81 @@ private class UserLibraryImpl( override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override suspend fun createPlaylist(name: String, songs: List) { + // TODO: Use synchronized with value access too val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } val rawPlaylist = RawPlaylist( PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw), playlistImpl.songs.map { PlaylistSong(it.uid) }) - playlistDao.insertPlaylist(rawPlaylist) + try { + playlistDao.insertPlaylist(rawPlaylist) + logD("Successfully created playlist $name with ${songs.size} songs") + } catch (e: Exception) { + logE("Unable to create playlist $name with ${songs.size} songs") + logE(e.stackTraceToString()) + synchronized(this) { playlistMap.remove(playlistImpl.uid) } + return + } } override suspend fun renamePlaylist(playlist: Playlist, name: String) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) } - playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name)) + try { + playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name)) + logD("Successfully renamed $playlist to $name") + } catch (e: Exception) { + logE("Unable to rename $playlist to $name: $e") + logE(e.stackTraceToString()) + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + return + } } override suspend fun deletePlaylist(playlist: Playlist) { - synchronized(this) { - requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot remove invalid playlist" } + synchronized(this) { playlistMap.remove(playlistImpl.uid) } + try { + playlistDao.deletePlaylist(playlist.uid) + logD("Successfully deleted $playlist") + } catch (e: Exception) { + logE("Unable to delete $playlist: $e") + logE(e.stackTraceToString()) + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + return } - playlistDao.deletePlaylist(playlist.uid) } override suspend fun addToPlaylist(playlist: Playlist, songs: List) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } - playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) + try { + playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) + logD("Successfully added ${songs.size} songs to $playlist") + } catch (e: Exception) { + logE("Unable to add ${songs.size} songs to $playlist: $e") + logE(e.stackTraceToString()) + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + return + } } override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) } - playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) + try { + playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) + logD("Successfully rewrote $playlist with ${songs.size} songs") + } catch (e: Exception) { + logE("Unable to rewrite $playlist with ${songs.size} songs: $e") + logE(e.stackTraceToString()) + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + return + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt index 087e46b35..9c46bbe78 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -18,7 +18,14 @@ package org.oxycblt.auxio.music.user -import androidx.room.* +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.TypeConverters import org.oxycblt.auxio.music.Music /** diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt index 6e2f43f83..5271e49c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) * - * TODO: This whole system is very jankily designed, perhaps it's time for a refactor? + * TODO: Unwind this into ViewModel-specific actions, and then reference those. */ class NavigationViewModel : ViewModel() { private val _mainNavigationAction = MutableEvent() @@ -96,6 +96,7 @@ class NavigationViewModel : ViewModel() { * dialog will be shown. */ fun exploreNavigateToParentArtist(song: Song) { + logD("Navigating to parent artist of $song") exploreNavigateToParentArtistImpl(song, song.artists) } @@ -106,6 +107,7 @@ class NavigationViewModel : ViewModel() { * dialog will be shown. */ fun exploreNavigateToParentArtist(album: Album) { + logD("Navigating to parent artist of $album") exploreNavigateToParentArtistImpl(album, album.artists) } diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt index a8614af77..ade74f930 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt @@ -78,7 +78,7 @@ class NavigateToArtistDialog : override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) - choiceAdapter + binding.choiceRecycler.adapter = null } override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt index b09b74ae9..f02621d5b 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt @@ -23,7 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD /** * A [ViewModel] that stores the current information required for navigation picker dialogs @@ -58,6 +63,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: } else -> null } + logD("Updated artist choices: ${_artistChoices.value}") } override fun onCleared() { @@ -71,12 +77,22 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: * @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album]. */ fun setArtistChoiceUid(itemUid: Music.UID) { + logD("Opening navigation choices for $itemUid") // Support Songs and Albums, which have parent artists. _artistChoices.value = when (val music = musicRepository.find(itemUid)) { - is Song -> SongArtistNavigationChoices(music) - is Album -> AlbumArtistNavigationChoices(music) - else -> null + is Song -> { + logD("Creating navigation choices for song") + SongArtistNavigationChoices(music) + } + is Album -> { + logD("Creating navigation choices for album") + AlbumArtistNavigationChoices(music) + } + else -> { + logD("Given song/album UID was invalid") + null + } } } } 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 f49d6df44..05f438c38 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback import android.os.Bundle import android.view.LayoutInflater import androidx.fragment.app.activityViewModels +import com.google.android.material.R as MR import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding @@ -33,6 +34,7 @@ import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getColorCompat +import org.oxycblt.auxio.util.logD /** * A [ViewBindingFragment] that shows the current playback state in a compact manner. @@ -92,14 +94,16 @@ class PlaybackBarFragment : ViewBindingFragment() { private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) { when (actionMode) { ActionMode.NEXT -> { + logD("Setting up skip next action") binding.playbackSecondaryAction.apply { setIconResource(R.drawable.ic_skip_next_24) contentDescription = getString(R.string.desc_skip_next) - iconTint = context.getAttrColorCompat(R.attr.colorOnSurfaceVariant) + iconTint = context.getAttrColorCompat(MR.attr.colorOnSurfaceVariant) setOnClickListener { playbackModel.next() } } } ActionMode.REPEAT -> { + logD("Setting up repeat mode action") binding.playbackSecondaryAction.apply { contentDescription = getString(R.string.desc_change_repeat) iconTint = context.getColorCompat(R.color.sel_activatable_icon) @@ -108,6 +112,7 @@ class PlaybackBarFragment : ViewBindingFragment() { } } ActionMode.SHUFFLE -> { + logD("Setting up shuffle action") binding.playbackSecondaryAction.apply { setIconResource(R.drawable.sel_shuffle_state_24) contentDescription = getString(R.string.desc_shuffle) @@ -120,14 +125,17 @@ class PlaybackBarFragment : ViewBindingFragment() { } private fun updateSong(song: Song?) { - if (song != null) { - val context = requireContext() - val binding = requireBinding() - binding.playbackCover.bind(song) - binding.playbackSong.text = song.name.resolve(context) - binding.playbackInfo.text = song.artists.resolveNames(context) - binding.playbackProgressBar.max = song.durationMs.msToDs().toInt() + if (song == null) { + // Nothing to do. + return } + + val context = requireContext() + val binding = requireBinding() + binding.playbackCover.bind(song) + binding.playbackSong.text = song.name.resolve(context) + binding.playbackInfo.text = song.artists.resolveNames(context) + binding.playbackProgressBar.max = song.durationMs.msToDs().toInt() } private fun updatePlaying(isPlaying: Boolean) { 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 a1a7b86b9..a2ed51882 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt @@ -24,6 +24,7 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.R as MR import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.ui.BaseBottomSheetBehavior @@ -39,7 +40,7 @@ class PlaybackBottomSheetBehavior(context: Context, attributeSet: Attr BaseBottomSheetBehavior(context, attributeSet) { val sheetBackgroundDrawable = MaterialShapeDrawable.createWithElevationOverlay(context).apply { - fillColor = context.getAttrColorCompat(R.attr.colorSurface) + fillColor = context.getAttrColorCompat(MR.attr.colorSurface) elevation = context.getDimen(R.dimen.elevation_normal) } 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 f7bad82e9..6bcc4114e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -43,6 +43,8 @@ import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.share import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.systemBarInsetsCompat @@ -141,6 +143,7 @@ class PlaybackPanelFragment : when (item.itemId) { R.id.action_open_equalizer -> { // Launch the system equalizer app, if possible. + logD("Launching equalizer") val equalizerIntent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) // Provide audio session ID so the equalizer can show options for this app @@ -180,6 +183,10 @@ class PlaybackPanelFragment : } true } + R.id.action_share -> { + playbackModel.song.value?.let { requireContext().share(it) } + true + } else -> false } @@ -195,6 +202,7 @@ class PlaybackPanelFragment : val binding = requireBinding() val context = requireContext() + logD("Updating song display: $song") binding.playbackCover.bind(song) binding.playbackSong.text = song.name.resolve(context) binding.playbackArtist.text = song.artists.resolveNames(context) @@ -228,13 +236,11 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - /** Navigate to one of the currently playing [Song]'s Artists. */ private fun navigateToCurrentArtist() { val song = playbackModel.song.value ?: return navModel.exploreNavigateToParentArtist(song) } - /** 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/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index 2a65c85a3..8ec5db941 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -198,8 +198,14 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont when (key) { getString(R.string.set_key_replay_gain), getString(R.string.set_key_pre_amp_with), - getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged() - getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged() + getString(R.string.set_key_pre_amp_without) -> { + logD("Dispatching ReplayGain setting change") + listener.onReplayGainSettingsChanged() + } + getString(R.string.set_key_notif_action) -> { + logD("Dispatching notification setting change") + listener.onNotificationActionChanged() + } } } 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 68f2cac16..115d00344 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -27,17 +27,30 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.queue.Queue -import org.oxycblt.auxio.playback.state.* +import org.oxycblt.auxio.playback.state.InternalPlayer +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent +import org.oxycblt.auxio.util.logD /** * An [ViewModel] that provides a safe UI frontend for the current playback state. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Debug subtle backwards movement of position on pause */ @HiltViewModel class PlaybackViewModel @@ -114,27 +127,32 @@ constructor( } override fun onIndexMoved(queue: Queue) { + logD("Index moved, updating current song") _song.value = queue.currentSong } override fun onQueueChanged(queue: Queue, change: Queue.Change) { // Other types of queue changes preserve the current song. if (change.type == Queue.Change.Type.SONG) { + logD("Queue changed, updating current song") _song.value = queue.currentSong } } override fun onQueueReordered(queue: Queue) { + logD("Queue completely changed, updating current song") _isShuffled.value = queue.isShuffled } override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + logD("New playback started, updating playback information") _song.value = queue.currentSong _parent.value = parent _isShuffled.value = queue.isShuffled } override fun onStateChanged(state: InternalPlayer.State) { + logD("Player state changed, starting new position polling") _isPlaying.value = state.isPlaying // Still need to update the position now due to co-routine launch delays _positionDs.value = state.calculateElapsedPositionMs().msToDs() @@ -159,6 +177,7 @@ constructor( /** Shuffle all songs in the music library. */ fun shuffleAll() { + logD("Shuffling all songs") playImpl(null, null, true) } @@ -174,6 +193,7 @@ constructor( * @param playbackMode The [MusicMode] to play from. */ fun playFrom(song: Song, playbackMode: MusicMode) { + logD("Playing $song from $playbackMode") when (playbackMode) { MusicMode.SONGS -> playImpl(song, null) MusicMode.ALBUMS -> playImpl(song, song.album) @@ -192,10 +212,13 @@ constructor( */ fun playFromArtist(song: Song, artist: Artist? = null) { if (artist != null) { + logD("Playing $song from $artist") playImpl(song, artist) } else if (song.artists.size == 1) { + logD("$song has one artist, playing from it") playImpl(song, song.artists[0]) } else { + logD("$song has multiple artists, showing choice dialog") _artistPlaybackPickerSong.put(song) } } @@ -209,10 +232,13 @@ constructor( */ fun playFromGenre(song: Song, genre: Genre? = null) { if (genre != null) { + logD("Playing $song from $genre") playImpl(song, genre) } else if (song.genres.size == 1) { + logD("$song has one genre, playing from it") playImpl(song, song.genres[0]) } else { + logD("$song has multiple genres, showing choice dialog") _genrePlaybackPickerSong.put(song) } } @@ -224,6 +250,7 @@ constructor( * @param playlist The [Playlist] to play from. Must be linked to the [Song]. */ fun playFromPlaylist(song: Song, playlist: Playlist) { + logD("Playing $song from $playlist") playImpl(song, playlist) } @@ -232,70 +259,100 @@ constructor( * * @param album The [Album] to play. */ - fun play(album: Album) = playImpl(null, album, false) + fun play(album: Album) { + logD("Playing $album") + playImpl(null, album, false) + } /** * Play an [Artist]. * * @param artist The [Artist] to play. */ - fun play(artist: Artist) = playImpl(null, artist, false) + fun play(artist: Artist) { + logD("Playing $artist") + playImpl(null, artist, false) + } /** * Play a [Genre]. * * @param genre The [Genre] to play. */ - fun play(genre: Genre) = playImpl(null, genre, false) + fun play(genre: Genre) { + logD("Playing $genre") + playImpl(null, genre, false) + } /** * Play a [Playlist]. * * @param playlist The [Playlist] to play. */ - fun play(playlist: Playlist) = playImpl(null, playlist, false) + fun play(playlist: Playlist) { + logD("Playing $playlist") + playImpl(null, playlist, false) + } /** * Play a list of [Song]s. * * @param songs The [Song]s to play. */ - fun play(songs: List) = playbackManager.play(null, null, songs, false) + fun play(songs: List) { + logD("Playing ${songs.size} songs") + playbackManager.play(null, null, songs, false) + } /** * Shuffle an [Album]. * * @param album The [Album] to shuffle. */ - fun shuffle(album: Album) = playImpl(null, album, true) + fun shuffle(album: Album) { + logD("Shuffling $album") + playImpl(null, album, true) + } /** * Shuffle an [Artist]. * * @param artist The [Artist] to shuffle. */ - fun shuffle(artist: Artist) = playImpl(null, artist, true) + fun shuffle(artist: Artist) { + logD("Shuffling $artist") + playImpl(null, artist, true) + } /** * Shuffle a [Genre]. * * @param genre The [Genre] to shuffle. */ - fun shuffle(genre: Genre) = playImpl(null, genre, true) + fun shuffle(genre: Genre) { + logD("Shuffling $genre") + playImpl(null, genre, true) + } /** * Shuffle a [Playlist]. * * @param playlist The [Playlist] to shuffle. */ - fun shuffle(playlist: Playlist) = playImpl(null, playlist, true) + fun shuffle(playlist: Playlist) { + logD("Shuffling $playlist") + playImpl(null, playlist, true) + } /** * Shuffle a list of [Song]s. * * @param songs The [Song]s to shuffle. */ - fun shuffle(songs: List) = playbackManager.play(null, null, songs, true) + fun shuffle(songs: List) { + logD("Shuffling ${songs.size} songs") + playbackManager.play(null, null, songs, true) + } private fun playImpl( song: Song?, @@ -324,6 +381,7 @@ constructor( * @param action The [InternalPlayer.Action] to perform eventually. */ fun startAction(action: InternalPlayer.Action) { + logD("Starting action $action") playbackManager.startAction(action) } @@ -335,6 +393,7 @@ constructor( * @param positionDs The position to seek to, in deci-seconds (1/10th of a second). */ fun seekTo(positionDs: Long) { + logD("Seeking to ${positionDs}ds") playbackManager.seekTo(positionDs.dsToMs()) } @@ -342,11 +401,13 @@ constructor( /** Skip to the next [Song]. */ fun next() { + logD("Skipping to next song") playbackManager.next() } /** Skip to the previous [Song]. */ fun prev() { + logD("Skipping to previous song") playbackManager.prev() } @@ -356,6 +417,7 @@ constructor( * @param song The [Song] to add. */ fun playNext(song: Song) { + logD("Playing $song next") playbackManager.playNext(song) } @@ -365,6 +427,7 @@ constructor( * @param album The [Album] to add. */ fun playNext(album: Album) { + logD("Playing $album next") playbackManager.playNext(musicSettings.albumSongSort.songs(album.songs)) } @@ -374,6 +437,7 @@ constructor( * @param artist The [Artist] to add. */ fun playNext(artist: Artist) { + logD("Playing $artist next") playbackManager.playNext(musicSettings.artistSongSort.songs(artist.songs)) } @@ -383,6 +447,7 @@ constructor( * @param genre The [Genre] to add. */ fun playNext(genre: Genre) { + logD("Playing $genre next") playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs)) } @@ -392,6 +457,7 @@ constructor( * @param playlist The [Playlist] to add. */ fun playNext(playlist: Playlist) { + logD("Playing $playlist next") playbackManager.playNext(playlist.songs) } @@ -401,6 +467,7 @@ constructor( * @param songs The [Song]s to add. */ fun playNext(songs: List) { + logD("Playing ${songs.size} songs next") playbackManager.playNext(songs) } @@ -410,6 +477,7 @@ constructor( * @param song The [Song] to add. */ fun addToQueue(song: Song) { + logD("Adding $song to queue") playbackManager.addToQueue(song) } @@ -419,6 +487,7 @@ constructor( * @param album The [Album] to add. */ fun addToQueue(album: Album) { + logD("Adding $album to queue") playbackManager.addToQueue(musicSettings.albumSongSort.songs(album.songs)) } @@ -428,6 +497,7 @@ constructor( * @param artist The [Artist] to add. */ fun addToQueue(artist: Artist) { + logD("Adding $artist to queue") playbackManager.addToQueue(musicSettings.artistSongSort.songs(artist.songs)) } @@ -437,6 +507,7 @@ constructor( * @param genre The [Genre] to add. */ fun addToQueue(genre: Genre) { + logD("Adding $genre to queue") playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs)) } @@ -446,6 +517,7 @@ constructor( * @param playlist The [Playlist] to add. */ fun addToQueue(playlist: Playlist) { + logD("Adding $playlist to queue") playbackManager.addToQueue(playlist.songs) } @@ -455,6 +527,7 @@ constructor( * @param songs The [Song]s to add. */ fun addToQueue(songs: List) { + logD("Adding ${songs.size} songs to queue") playbackManager.addToQueue(songs) } @@ -462,11 +535,13 @@ constructor( /** Toggle [isPlaying] (i.e from playing to paused) */ fun togglePlaying() { + logD("Toggling playing state") playbackManager.setPlaying(!playbackManager.playerState.isPlaying) } /** Toggle [isShuffled] (ex. from on to off) */ fun toggleShuffled() { + logD("Toggling shuffled state") playbackManager.reorder(!playbackManager.queue.isShuffled) } @@ -476,6 +551,7 @@ constructor( * @see RepeatMode.increment */ fun toggleRepeatMode() { + logD("Toggling repeat mode") playbackManager.repeatMode = playbackManager.repeatMode.increment() } @@ -487,6 +563,7 @@ constructor( * @param onDone Called when the save is completed with true if successful, and false otherwise. */ fun savePlaybackState(onDone: (Boolean) -> Unit) { + logD("Saving playback state") viewModelScope.launch { onDone(persistenceRepository.saveState(playbackManager.toSavedState())) } @@ -498,6 +575,7 @@ constructor( * @param onDone Called when the wipe is completed with true if successful, and false otherwise. */ fun wipePlaybackState(onDone: (Boolean) -> Unit) { + logD("Wiping playback state") viewModelScope.launch { onDone(persistenceRepository.saveState(null)) } } @@ -508,6 +586,7 @@ constructor( * otherwise. */ fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) { + logD("Force-restoring playback state") viewModelScope.launch { val savedState = persistenceRepository.readState() if (savedState != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index 545038207..93e387068 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -127,6 +127,7 @@ interface QueueDao { } // TODO: Figure out how to get RepeatMode to map to an int instead of a string +// TODO: Use intrinsic table names rather than custom names @Entity(tableName = PlaybackState.TABLE_NAME) data class PlaybackState( @PrimaryKey val id: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt index a246689fe..00b1a8894 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt @@ -61,7 +61,7 @@ constructor( heap = queueDao.getHeap() mapping = queueDao.getMapping() } catch (e: Exception) { - logE("Unable to load playback state data") + logE("Unable read playback state") logE(e.stackTraceToString()) return null } @@ -74,7 +74,7 @@ constructor( } val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent } - logD("Read playback state") + logD("Successfully read playback state") return PlaybackStateManager.SavedState( parent = parent, @@ -90,8 +90,6 @@ constructor( } override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean { - // Only bother saving a state if a song is actively playing from one. - // This is not the case with a null state. try { playbackStateDao.nukeState() queueDao.nukeHeap() @@ -101,7 +99,8 @@ constructor( logE(e.stackTraceToString()) return false } - logD("Cleared state") + + logD("Successfully cleared previous state") if (state != null) { // Transform saved state into raw state, which can then be written to the database. val playbackState = @@ -118,12 +117,14 @@ constructor( state.queueState.heap.mapIndexed { i, song -> QueueHeapItem(i, requireNotNull(song).uid) } + val mapping = state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed { i, pair -> QueueMappingItem(i, pair.first, pair.second) } + try { playbackStateDao.insertState(playbackState) queueDao.insertHeap(heap) @@ -133,8 +134,10 @@ constructor( logE(e.stackTraceToString()) return false } - logD("Wrote state") + + logD("Successfully wrote new state") } + return true } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt index 0d477bd8d..c8fc134e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -72,6 +73,7 @@ class PlayFromArtistDialog : if (it != null) { choiceAdapter.update(it.artists, UpdateInstructions.Replace(0)) } else { + logD("No song to show choices for, navigating away") findNavController().navigateUp() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 0b8914dc2..1f2693a10 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -72,6 +73,7 @@ class PlayFromGenreDialog : if (it != null) { choiceAdapter.update(it.genres, UpdateInstructions.Replace(0)) } else { + logD("No song to show choices for, navigating away") findNavController().navigateUp() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt index 577b93c50..644b5a580 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt @@ -23,7 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW /** * A [ViewModel] that stores the choices shown in the playback picker dialogs. @@ -59,6 +64,10 @@ class PlaybackPickerViewModel @Inject constructor(private val musicRepository: M * @param uid The [Music.UID] of the item to show. Must be a [Song]. */ fun setPickerSongUid(uid: Music.UID) { + logD("Opening picker for song $uid") _currentPickerSong.value = musicRepository.deviceLibrary?.findSong(uid) + if (_currentPickerSong.value != null) { + logW("Given song UID was invalid") + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt index 434e8f479..c5ebf9904 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt @@ -23,6 +23,7 @@ import kotlin.random.nextInt import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD /** * A heap-backed play queue. @@ -106,7 +107,7 @@ interface Queue { } } -class EditableQueue : Queue { +class MutableQueue : Queue { @Volatile private var heap = mutableListOf() @Volatile private var orderedMapping = mutableListOf() @Volatile private var shuffledMapping = mutableListOf() @@ -174,6 +175,8 @@ class EditableQueue : Queue { return } + logD("Reordering queue [shuffled=$shuffled]") + if (shuffled) { val trueIndex = if (shuffledMapping.isNotEmpty()) { @@ -190,7 +193,7 @@ class EditableQueue : Queue { shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex))) index = 0 } else if (shuffledMapping.isNotEmpty()) { - // Un-shuffling, song to preserve is in the shuffled mapping. + // Ordering queue, song to preserve is in the shuffled mapping. index = orderedMapping.indexOf(shuffledMapping[index]) shuffledMapping = mutableListOf() } @@ -198,26 +201,30 @@ class EditableQueue : Queue { } /** - * Add [Song]s to the top of the queue. Will start playback if nothing is playing. + * Add [Song]s to the "top" of the queue (right next to the currently playing song). Will start + * playback if nothing is playing. * * @param songs The [Song]s to add. * @return A [Queue.Change] instance that reflects the changes made. */ - fun playNext(songs: List): Queue.Change { + fun addToTop(songs: List): Queue.Change { + logD("Adding ${songs.size} songs to the front of the queue") + val insertAt = index + 1 val heapIndices = songs.map(::addSongToHeap) if (shuffledMapping.isNotEmpty()) { // Add the new songs in front of the current index in the shuffled mapping and in front // of the analogous list song in the ordered mapping. + logD("Must append songs to shuffled mapping") val orderedIndex = orderedMapping.indexOf(shuffledMapping[index]) orderedMapping.addAll(orderedIndex + 1, heapIndices) - shuffledMapping.addAll(index + 1, heapIndices) + shuffledMapping.addAll(insertAt, heapIndices) } else { // Add the new song in front of the current index in the ordered mapping. - orderedMapping.addAll(index + 1, heapIndices) + logD("Only appending songs to ordered mapping") + orderedMapping.addAll(insertAt, heapIndices) } check() - return Queue.Change( - Queue.Change.Type.MAPPING, UpdateInstructions.Add(index + 1, songs.size)) + return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size)) } /** @@ -226,16 +233,18 @@ class EditableQueue : Queue { * @param songs The [Song]s to add. * @return A [Queue.Change] instance that reflects the changes made. */ - fun addToQueue(songs: List): Queue.Change { + fun addToBottom(songs: List): Queue.Change { + logD("Adding ${songs.size} songs to the back of the queue") + val insertAt = orderedMapping.size val heapIndices = songs.map(::addSongToHeap) // Can simple append the new songs to the end of both mappings. orderedMapping.addAll(heapIndices) if (shuffledMapping.isNotEmpty()) { + logD("Appending songs to shuffled mapping") shuffledMapping.addAll(heapIndices) } check() - return Queue.Change( - Queue.Change.Type.MAPPING, UpdateInstructions.Add(index + 1, songs.size)) + return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size)) } /** @@ -255,19 +264,33 @@ class EditableQueue : Queue { orderedMapping.add(dst, orderedMapping.removeAt(src)) } + val oldIndex = index when (index) { // We are moving the currently playing song, correct the index to it's new position. - src -> index = dst + src -> { + logD("Moving current song, shifting index") + index = dst + } // We have moved an song from behind the playing song to in front, shift back. - in (src + 1)..dst -> index -= 1 + in (src + 1)..dst -> { + logD("Moving song from behind -> front, shift backwards") + index -= 1 + } // We have moved an song from in front of the playing song to behind, shift forward. - in dst until src -> index += 1 + in dst until src -> { + logD("Moving song from front -> behind, shift forward") + index += 1 + } else -> { // Nothing to do. + logD("Move preserved index") check() return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Move(src, dst)) } } + + logD("Move changed index: $oldIndex -> $index") + check() return Queue.Change(Queue.Change.Type.INDEX, UpdateInstructions.Move(src, dst)) } @@ -279,6 +302,7 @@ class EditableQueue : Queue { * @return A [Queue.Change] instance that reflects the changes made. */ fun remove(at: Int): Queue.Change { + val lastIndex = orderedMapping.lastIndex if (shuffledMapping.isNotEmpty()) { // Remove the specified index in the shuffled mapping and the analogous song in the // ordered mapping. @@ -296,15 +320,27 @@ class EditableQueue : Queue { val type = when { // We just removed the currently playing song. - index == at -> Queue.Change.Type.SONG + index == at -> { + logD("Removed current song") + if (lastIndex == index) { + logD("Current song at end of queue, shift back") + --index + } + Queue.Change.Type.SONG + } // Index was ahead of removed song, shift back to preserve consistency. index > at -> { - index -= 1 + logD("Removed before current song, shift back") + --index Queue.Change.Type.INDEX } // Nothing to do - else -> Queue.Change.Type.MAPPING + else -> { + logD("Removal preserved index") + Queue.Change.Type.MAPPING + } } + logD("Committing change of type $type") check() return Queue.Change(type, UpdateInstructions.Remove(at, 1)) } @@ -337,6 +373,8 @@ class EditableQueue : Queue { } } + logD("Created adjustment mapping [max shift=$currentShift]") + heap = savedState.heap.filterNotNull().toMutableList() orderedMapping = savedState.orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex -> @@ -352,6 +390,7 @@ class EditableQueue : Queue { while (currentSong?.uid != savedState.songUid && index > -1) { index-- } + logD("Corrected index: ${savedState.index} -> $index") check() } @@ -371,13 +410,17 @@ class EditableQueue : Queue { orphanCandidates.add(entry.index) } } + logD("Found orphans: ${orphanCandidates.map { heap[it] }}") orphanCandidates.removeAll(currentMapping.toSet()) if (orphanCandidates.isNotEmpty()) { + val orphan = orphanCandidates.first() + logD("Found an orphan that could be re-used: ${heap[orphan]}") // There are orphaned songs, return the first one we find. - return orphanCandidates.first() + return orphan } } // Nothing to re-use, add this song to the queue + logD("No orphan could be re-used") heap.add(song) return heap.lastIndex } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 76625a038..350ec3daa 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -24,16 +24,22 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isInvisible import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R as MR import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemEditableSongBinding import org.oxycblt.auxio.list.EditClickListListener -import org.oxycblt.auxio.list.adapter.* +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.getAttrColorCompat +import org.oxycblt.auxio.util.getDimen +import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * A [RecyclerView.Adapter] that shows an editable list of queue items. @@ -82,9 +88,13 @@ class QueueAdapter(private val listener: EditClickListListener) : // Have to update not only the currently playing item, but also all items marked // as playing. + // TODO: Optimize this by only updating the range between old and new indices? + // TODO: Don't update when the index has not moved. if (currentIndex < lastIndex) { + logD("Moved backwards, must update items above last index") notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION) } else { + logD("Moved forwards, update items after index") notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION) } @@ -110,11 +120,15 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS override val delete = binding.background override val background = MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { - fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) + fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface) elevation = binding.context.getDimen(R.dimen.elevation_normal) * 5 alpha = 0 } + /** + * Whether this ViewHolder should be full-opacity to represent a future item, or greyed out to + * represent a past item. True if former, false if latter. + */ var isFuture: Boolean get() = binding.songAlbumCover.isEnabled set(value) { @@ -128,7 +142,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS LayerDrawable( arrayOf( MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply { - fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) + fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface) elevation = binding.context.getDimen(R.dimen.elevation_normal) }, background)) @@ -153,7 +167,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { binding.interactBody.isSelected = isActive - binding.songAlbumCover.isPlaying = isPlaying + binding.songAlbumCover.setPlaying(isPlaying) } companion object { 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 d2043f373..ddf70b00d 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 @@ -23,6 +23,7 @@ import android.util.AttributeSet import android.view.View import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.R as MR import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.ui.BaseBottomSheetBehavior @@ -64,7 +65,7 @@ class QueueBottomSheetBehavior(context: Context, attributeSet: Attribu override fun createBackground(context: Context) = MaterialShapeDrawable.createWithElevationOverlay(context).apply { // The queue sheet's background is a static elevated background. - fillColor = context.getAttrColorCompat(R.attr.colorSurface) + fillColor = context.getAttrColorCompat(MR.attr.colorSurface) elevation = context.getDimen(R.dimen.elevation_normal) } 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 414ab0eeb..2db007971 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 @@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD /** * A [ViewBindingFragment] that displays an editable queue. @@ -104,9 +105,7 @@ class QueueFragment : ViewBindingFragment(), EditClickList private fun updateQueue(queue: List, index: Int, isPlaying: Boolean) { val binding = requireBinding() - // Replace or diff the queue depending on the type of change it is. queueAdapter.update(queue, queueModel.queueInstructions.consume()) - // Update position in list (and thus past/future items) queueAdapter.setPosition(index, isPlaying) // If requested, scroll to a new item (occurs when the index moves) @@ -122,13 +121,15 @@ class QueueFragment : ViewBindingFragment(), EditClickList // dependent on where we have to scroll to get to the currently playing song. if (notInitialized || scrollTo < start) { // We need to scroll upwards, or initialize the scroll, no need to offset + logD("Not scrolling downwards, no offset needed") binding.queueRecycler.scrollToPosition(scrollTo) } else if (scrollTo > end) { // We need to scroll downwards, we need to offset by a screen of songs. // This does have some error due to how many completely visible items on-screen // can vary. This is considered okay. - binding.queueRecycler.scrollToPosition( - min(queue.lastIndex, scrollTo + (end - start))) + val offset = scrollTo + (end - start) + logD("Scrolling downwards, offsetting by $offset") + binding.queueRecycler.scrollToPosition(min(queue.lastIndex, offset)) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 13099ef7b..5b1edce73 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent +import org.oxycblt.auxio.util.logD /** * A [ViewModel] that manages the current queue state and allows navigation through the queue. @@ -60,22 +61,26 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt } override fun onIndexMoved(queue: Queue) { + logD("Index moved, synchronizing and scrolling to new position") _scrollTo.put(queue.index) _index.value = queue.index } override fun onQueueChanged(queue: Queue, change: Queue.Change) { // Queue changed trivially due to item mo -> Diff queue, stay at current index. + logD("Updating queue display") _queueInstructions.put(change.instructions) _queue.value = queue.resolve() if (change.type != Queue.Change.Type.MAPPING) { // Index changed, make sure it remains updated without actually scrolling to it. + logD("Index changed with queue, synchronizing new position") _index.value = queue.index } } override fun onQueueReordered(queue: Queue) { // Queue changed completely -> Replace queue, update index + logD("Queue changed completely, replacing queue and position") _queueInstructions.put(UpdateInstructions.Replace(0)) _scrollTo.put(queue.index) _queue.value = queue.resolve() @@ -84,6 +89,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt override fun onNewPlayback(queue: Queue, parent: MusicParent?) { // Entirely new queue -> Replace queue, update index + logD("New playback, replacing queue and position") _queueInstructions.put(UpdateInstructions.Replace(0)) _scrollTo.put(queue.index) _queue.value = queue.resolve() @@ -102,6 +108,10 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt * range. */ fun goto(adapterIndex: Int) { + if (adapterIndex !in queue.value.indices) { + return + } + logD("Going to position $adapterIndex in queue") playbackManager.goto(adapterIndex) } @@ -115,6 +125,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt if (adapterIndex !in queue.value.indices) { return } + logD("Removing item $adapterIndex in queue") playbackManager.removeQueueItem(adapterIndex) } @@ -129,6 +140,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt if (adapterFrom !in queue.value.indices || adapterTo !in queue.value.indices) { return false } + logD("Moving $adapterFrom to $adapterFrom in queue") playbackManager.moveQueueItem(adapterFrom, adapterTo) return true } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index 07815dde4..dcd7db42e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.logD /** * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp]. @@ -61,6 +62,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { // settings. After this, the sliders save their own state, so we do not need to // do any restore behavior. val preAmp = playbackSettings.replayGainPreAmp + logD("Initializing from $preAmp") binding.withTagsSlider.value = preAmp.with binding.withoutTagsSlider.value = preAmp.without } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 7bb57376c..ab86651e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -125,14 +125,22 @@ constructor( when (playbackSettings.replayGainMode) { // User wants track gain to be preferred. Default to album gain only if // there is no track gain. - ReplayGainMode.TRACK -> gain.track == 0f + ReplayGainMode.TRACK -> { + logD("Using track strategy") + gain.track == 0f + } // User wants album gain to be preferred. Default to track gain only if // here is no album gain. - ReplayGainMode.ALBUM -> gain.album != 0f + ReplayGainMode.ALBUM -> { + logD("Using album strategy") + gain.album != 0f + } // User wants album gain to be used when in an album, track gain otherwise. - ReplayGainMode.DYNAMIC -> + ReplayGainMode.DYNAMIC -> { + logD("Using dynamic strategy") playbackManager.parent is Album && playbackManager.queue.currentSong?.album == playbackManager.parent + } } val resolvedGain = @@ -184,6 +192,7 @@ constructor( textTags.vorbis[TAG_RG_TRACK_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it } + // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the // adjustment by 256 to get the gain. This is used alongside the base adjustment // intrinsic to the format to create the normalized adjustment. This is normally the only 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 63fb85ed2..388087653 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 @@ -22,7 +22,7 @@ import javax.inject.Inject import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.queue.EditableQueue +import org.oxycblt.auxio.playback.queue.MutableQueue import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -305,10 +305,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Volatile private var pendingAction: InternalPlayer.Action? = null @Volatile private var isInitialized = false - override val queue = EditableQueue() + override val queue = MutableQueue() @Volatile - override var parent: MusicParent? = - null // FIXME: Parent is interpreted wrong when nothing is playing. + override var parent: MusicParent? = null private set @Volatile override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) @@ -324,6 +323,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Synchronized override fun addListener(listener: PlaybackStateManager.Listener) { + logD("Adding $listener to listeners") if (isInitialized) { listener.onNewPlayback(queue, parent) listener.onRepeatChanged(repeatMode) @@ -335,7 +335,10 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Synchronized override fun removeListener(listener: PlaybackStateManager.Listener) { - listeners.remove(listener) + logD("Removing $listener from listeners") + if (!listeners.remove(listener)) { + logW("Listener $listener was not added prior, cannot remove") + } } @Synchronized @@ -345,6 +348,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { return } + logD("Registering internal player $internalPlayer") + if (isInitialized) { internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) internalPlayer.seekTo(playerState.calculateElapsedPositionMs()) @@ -364,6 +369,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { return } + logD("Unregistering internal player $internalPlayer") + this.internalPlayer = null } @@ -372,6 +379,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Synchronized override fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { val internalPlayer = internalPlayer ?: return + logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]") // Set up parent and queue this.parent = parent this.queue.start(song, queue, shuffled) @@ -391,6 +399,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { if (!queue.goto(queue.index + 1)) { queue.goto(0) play = repeatMode == RepeatMode.ALL + logD("At end of queue, wrapping around to position 0 [play=$play]") + } else { + logD("Moving to next song") } notifyIndexMoved() internalPlayer.loadSong(queue.currentSong, play) @@ -399,12 +410,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Synchronized override fun prev() { val internalPlayer = internalPlayer ?: return - // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] if (internalPlayer.shouldRewindWithPrev) { + logD("Rewinding current song") rewind() setPlaying(true) } else { + logD("Moving to previous song") if (!queue.goto(queue.index - 1)) { queue.goto(0) } @@ -417,26 +429,33 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { override fun goto(index: Int) { val internalPlayer = internalPlayer ?: return if (queue.goto(index)) { + logD("Moving to $index") notifyIndexMoved() internalPlayer.loadSong(queue.currentSong, true) + } else { + logW("$index was not in bounds, could not move to it") } } @Synchronized override fun playNext(songs: List) { if (queue.currentSong == null) { + logD("Nothing playing, short-circuiting to new playback") play(songs[0], null, songs, false) } else { - notifyQueueChanged(queue.playNext(songs)) + logD("Adding ${songs.size} songs to start of queue") + notifyQueueChanged(queue.addToTop(songs)) } } @Synchronized override fun addToQueue(songs: List) { if (queue.currentSong == null) { + logD("Nothing playing, short-circuiting to new playback") play(songs[0], null, songs, false) } else { - notifyQueueChanged(queue.addToQueue(songs)) + logD("Adding ${songs.size} songs to end of queue") + notifyQueueChanged(queue.addToBottom(songs)) } } @@ -459,6 +478,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Synchronized override fun reorder(shuffled: Boolean) { + logD("Reordering queue [shuffled=$shuffled]") queue.reorder(shuffled) notifyQueueReordered() } @@ -503,11 +523,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Synchronized override fun setPlaying(isPlaying: Boolean) { + logD("Updating playing state to $isPlaying") internalPlayer?.setPlaying(isPlaying) } @Synchronized override fun seekTo(positionMs: Long) { + logD("Seeking to ${positionMs}ms") internalPlayer?.seekTo(positionMs) } @@ -529,10 +551,11 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { destructive: Boolean ) { if (isInitialized && !destructive) { + logW("Already initialized, cannot apply saved state") return } val internalPlayer = internalPlayer ?: return - logD("Restoring state $savedState") + logD("Applying state $savedState") val lastSong = queue.currentSong parent = savedState.parent @@ -544,10 +567,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { // it be. Specifically done so we don't pause on music updates that don't really change // what's playing (ex. playlist editing) if (lastSong != queue.currentSong) { + logD("Song changed, must reload player") // Continuing playback while also possibly doing drastic state updates is // a bad idea, so pause. internalPlayer.loadSong(queue.currentSong, false) if (queue.currentSong != null) { + logD("Seeking to saved position ${savedState.positionMs}ms") // Internal player may have reloaded the media item, re-seek to the previous // position seekTo(savedState.positionMs) @@ -559,36 +584,42 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { // --- CALLBACKS --- private fun notifyIndexMoved() { + logD("Dispatching index change") for (callback in listeners) { callback.onIndexMoved(queue) } } private fun notifyQueueChanged(change: Queue.Change) { + logD("Dispatching queue change $change") for (callback in listeners) { callback.onQueueChanged(queue, change) } } private fun notifyQueueReordered() { + logD("Dispatching queue reordering") for (callback in listeners) { callback.onQueueReordered(queue) } } private fun notifyNewPlayback() { + logD("Dispatching new playback") for (callback in listeners) { callback.onNewPlayback(queue, parent) } } private fun notifyStateChanged() { + logD("Dispatching player state change") for (callback in listeners) { callback.onStateChanged(playerState) } } private fun notifyRepeatModeChanged() { + logD("Dispatching repeat mode change") for (callback in listeners) { callback.onRepeatChanged(repeatMode) } 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 6c19d42b6..b875636b8 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 @@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD /** * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService]. @@ -43,6 +44,7 @@ class MediaButtonReceiver : BroadcastReceiver() { // stupid this is with the state of foreground services on modern android. One // wrong action at the wrong time will result in the app crashing, and there is // nothing I can do about it. + logD("Delivering media button intent $intent") intent.component = ComponentName(context, PlaybackService::class.java) ContextCompat.startForegroundService(context, 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 1d44ebc46..9d273ac98 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 @@ -86,6 +86,7 @@ constructor( * @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward. */ fun handleMediaButtonIntent(intent: Intent) { + logD("Forwarding $intent to MediaButtonReciever") MediaButtonReceiver.handleIntent(mediaSession, intent) } @@ -283,8 +284,10 @@ constructor( * playback is currently occuring from all songs. */ private fun updateMediaMetadata(song: Song?, parent: MusicParent?) { + logD("Updating media metadata to $song with $parent") if (song == null) { // Nothing playing, reset the MediaSession and close the notification. + logD("Nothing playing, resetting media session") mediaSession.setMetadata(emptyMetadata) return } @@ -316,12 +319,17 @@ constructor( .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 { + logD("Adding track information") builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) } song.disc?.let { + logD("Adding disc information") builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong()) } - song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) } + song.date?.let { + logD("Adding date information") + 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, @@ -330,6 +338,8 @@ constructor( song, object : BitmapProvider.Target { override fun onCompleted(bitmap: Bitmap?) { + this@MediaSessionComponent.logD( + "Bitmap loaded, applying media " + "session and posting notification") builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) val metadata = builder.build() @@ -364,6 +374,7 @@ constructor( // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) } + logD("Uploading ${queueItems.size} songs to MediaSession queue") mediaSession.setQueue(queueItems) } @@ -384,7 +395,8 @@ constructor( // Add the secondary action (either repeat/shuffle depending on the configuration) val secondaryAction = when (playbackSettings.notificationAction) { - ActionMode.SHUFFLE -> + ActionMode.SHUFFLE -> { + logD("Using shuffle MediaSession action") PlaybackStateCompat.CustomAction.Builder( PlaybackService.ACTION_INVERT_SHUFFLE, context.getString(R.string.desc_shuffle), @@ -393,11 +405,14 @@ constructor( } else { R.drawable.ic_shuffle_off_24 }) - else -> + } + else -> { + logD("Using repeat mode MediaSession action") PlaybackStateCompat.CustomAction.Builder( PlaybackService.ACTION_INC_REPEAT_MODE, context.getString(R.string.desc_change_repeat), playbackManager.repeatMode.icon) + } } state.addCustomAction(secondaryAction.build()) @@ -415,14 +430,22 @@ constructor( /** Invalidate the "secondary" action (i.e shuffle/repeat mode). */ private fun invalidateSecondaryAction() { + logD("Invalidating secondary action") invalidateSessionState() when (playbackSettings.notificationAction) { - ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled) - else -> notification.updateRepeatMode(playbackManager.repeatMode) + ActionMode.SHUFFLE -> { + logD("Using shuffle notification action") + notification.updateShuffled(playbackManager.queue.isShuffled) + } + else -> { + logD("Using repeat mode notification action") + notification.updateRepeatMode(playbackManager.repeatMode) + } } if (!bitmapProvider.isBusy) { + logD("Not loading a bitmap, post the notification") listener?.onPostNotification(notification) } } 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 a6410a274..7b9868072 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 @@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import androidx.annotation.DrawableRes @@ -31,6 +30,7 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.service.ForegroundServiceNotification +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -73,18 +73,11 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes * @param metadata The [MediaMetadataCompat] to display in this notification. */ fun updateMetadata(metadata: MediaMetadataCompat) { + logD("Updating shown metadata") setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)) setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)) setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) - - // Starting in API 24, the subtext field changed semantics from being below the - // content text to being above the title. Use an appropriate field for both. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - // Display description -> Parent in which playback is occurring - setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) - } else { - setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM)) - } + setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) } /** @@ -93,6 +86,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes * @param isPlaying Whether playback should be indicated as ongoing or paused. */ fun updatePlaying(isPlaying: Boolean) { + logD("Updating playing state: $isPlaying") mActions[2] = buildPlayPauseAction(context, isPlaying) } @@ -102,6 +96,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes * @param repeatMode The current [RepeatMode]. */ fun updateRepeatMode(repeatMode: RepeatMode) { + logD("Applying repeat mode action: $repeatMode") mActions[0] = buildRepeatAction(context, repeatMode) } @@ -111,6 +106,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes * @param isShuffled Whether the queue is currently shuffled or not. */ fun updateShuffled(isShuffled: Boolean) { + logD("Applying shuffle action: $isShuffled") mActions[0] = buildShuffleAction(context, isShuffled) } 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 18c948326..848d47b4d 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 @@ -26,7 +26,11 @@ import android.content.IntentFilter import android.media.AudioManager import android.media.audiofx.AudioEffect import android.os.IBinder -import androidx.media3.common.* +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.RenderersFactory @@ -40,6 +44,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -52,6 +57,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider @@ -102,8 +108,8 @@ class PlaybackService : // Coroutines private val serviceJob = Job() - private val restoreScope = CoroutineScope(serviceJob + Dispatchers.Main) - private val saveScope = CoroutineScope(serviceJob + Dispatchers.Main) + private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO) + private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO) // --- SERVICE OVERRIDES --- @@ -221,13 +227,16 @@ class PlaybackService : if (song == null) { // No song, stop playback and foreground state. logD("Nothing playing, stopping playback") + // For some reason the player does not mark playWhenReady as false when stopped, + // which then completely breaks any re-initialization if playback starts again. + // So we manually set it to false here. + player.playWhenReady = false player.stop() - stopAndSave() return } - logD("Loading ${song.name}") + logD("Loading $song") player.setMediaItem(MediaItem.fromUri(song.uri)) player.prepare() player.playWhenReady = play @@ -239,6 +248,7 @@ class PlaybackService : } override fun setPlaying(isPlaying: Boolean) { + logD("Updating player state to $isPlaying") player.playWhenReady = isPlaying } @@ -250,14 +260,17 @@ class PlaybackService : if (player.playWhenReady) { // Mark that we have started playing so that the notification can now be posted. hasPlayed = true + logD("Player has started playing") if (!openAudioEffectSession) { // Convention to start an audioeffect session on play/pause rather than // start/stop + logD("Opening audio effect session") broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) openAudioEffectSession = true } } else if (openAudioEffectSession) { // Make sure to close the audio session when we stop playback. + logD("Closing audio effect session") broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) openAudioEffectSession = false } @@ -269,6 +282,7 @@ class PlaybackService : Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_POSITION_DISCONTINUITY)) { + logD("Player state changed, must synchronize state") playbackManager.synchronizeState(this) } } @@ -277,12 +291,15 @@ class PlaybackService : if (state == Player.STATE_ENDED) { // Player ended, repeat the current track if we are configured to. if (playbackManager.repeatMode == RepeatMode.TRACK) { + logD("Looping current track") playbackManager.rewind() // May be configured to pause when we repeat a track. if (playbackSettings.pauseOnRepeat) { + logD("Pausing track on loop") playbackManager.setPlaying(false) } } else { + logD("Track ended, moving to next track") playbackManager.next() } } @@ -291,12 +308,15 @@ class PlaybackService : override fun onPlayerError(error: PlaybackException) { // TODO: Replace with no skipping and a notification instead // If there's any issue, just go to the next song. + logE("Player error occured") + logE(error.stackTraceToString()) playbackManager.next() } override fun onMusicChanges(changes: MusicRepository.Changes) { if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { // We now have a library, see if we have anything we need to do. + logD("Library obtained, requesting action") playbackManager.requestAction(this) } } @@ -304,6 +324,7 @@ class PlaybackService : // --- OTHER FUNCTIONS --- private fun broadcastAudioEffectAction(event: String) { + logD("Broadcasting AudioEffect event: $event") sendBroadcast( Intent(event) .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) @@ -329,24 +350,27 @@ class PlaybackService : // No library, cannot do anything. ?: return false - logD("Performing action: $action") - when (action) { // Restore state -> Start a new restoreState job is InternalPlayer.Action.RestoreState -> { + logD("Restoring playback state") restoreScope.launch { persistenceRepository.readState()?.let { - playbackManager.applySavedState(it, false) + // Apply the saved state on the main thread to prevent code expecting + // state updates on the main thread from crashing. + withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } } } } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { + logD("Shuffling all tracks") playbackManager.play( null, null, musicSettings.songSort.songs(deviceLibrary.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { + logD("Opening specified file") deviceLibrary.findSongForUri(application, action.uri)?.let { song -> playbackManager.play( song, @@ -367,8 +391,9 @@ class PlaybackService : // where changing a setting would cause the notification to appear in an unfriendly // manner. if (hasPlayed) { - logD("Updating notification") + logD("Played before, starting foreground state") if (!foregroundManager.tryStartForeground(notification)) { + logD("Notification changed, re-posting") notification.post() } } @@ -393,6 +418,7 @@ class PlaybackService : // 3. Some internal framework thing that also handles bluetooth headsets // Just use ACTION_HEADSET_PLUG. AudioManager.ACTION_HEADSET_PLUG -> { + logD("Received headset plug event") when (intent.getIntExtra("state", -1)) { 0 -> pauseFromHeadsetPlug() 1 -> playFromHeadsetPlug() @@ -400,21 +426,41 @@ class PlaybackService : initialHeadsetPlugEventHandled = true } - AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromHeadsetPlug() + AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { + logD("Received Headset noise event") + pauseFromHeadsetPlug() + } // --- AUXIO EVENTS --- - ACTION_PLAY_PAUSE -> + ACTION_PLAY_PAUSE -> { + logD("Received play event") playbackManager.setPlaying(!playbackManager.playerState.isPlaying) - ACTION_INC_REPEAT_MODE -> + } + ACTION_INC_REPEAT_MODE -> { + logD("Received repeat mode event") playbackManager.repeatMode = playbackManager.repeatMode.increment() - ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled) - ACTION_SKIP_PREV -> playbackManager.prev() - ACTION_SKIP_NEXT -> playbackManager.next() + } + ACTION_INVERT_SHUFFLE -> { + logD("Received shuffle event") + playbackManager.reorder(!playbackManager.queue.isShuffled) + } + ACTION_SKIP_PREV -> { + logD("Received skip previous event") + playbackManager.prev() + } + ACTION_SKIP_NEXT -> { + logD("Received skip next event") + playbackManager.next() + } ACTION_EXIT -> { + logD("Received exit event") playbackManager.setPlaying(false) stopAndSave() } - WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update() + WidgetProvider.ACTION_WIDGET_UPDATE -> { + logD("Received widget update event") + widgetComponent.update() + } } } 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 7023e9361..14d4f78a9 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,6 +25,7 @@ import com.google.android.material.button.MaterialButton import org.oxycblt.auxio.R import org.oxycblt.auxio.ui.RippleFixMaterialButton import org.oxycblt.auxio.util.getInteger +import org.oxycblt.auxio.util.logD /** * A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when @@ -32,10 +33,17 @@ import org.oxycblt.auxio.util.getInteger * * @author Alexander Capehart (OxygenCobalt) */ -class AnimatedMaterialButton -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : - RippleFixMaterialButton(context, attrs, defStyleAttr) { +class AnimatedMaterialButton : RippleFixMaterialButton { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor( + context: Context, + attrs: AttributeSet, + defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) + private var currentCornerRadiusRatio = 0f private var animator: ValueAnimator? = null @@ -46,10 +54,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 val targetRadius = if (activated) 0.3f else 0.5f if (!isLaidOut) { // Not laid out, initialize it without animation before drawing. + logD("Not laid out, immediately updating corner radius") updateCornerRadiusRatio(targetRadius) return } + logD("Starting corner radius animation") animator?.cancel() animator = ValueAnimator.ofFloat(currentCornerRadiusRatio, targetRadius).apply { 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 e37cdf660..20bbd9394 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 @@ -81,6 +81,7 @@ 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 + logD("Value sanitization finished [to=$to, enabled=$isEnabled]") // 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) { diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 4c1b2c2a7..a800221fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -20,11 +20,25 @@ package org.oxycblt.auxio.search import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.list.* +import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback -import org.oxycblt.auxio.list.recycler.* -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.list.recycler.AlbumViewHolder +import org.oxycblt.auxio.list.recycler.ArtistViewHolder +import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder +import org.oxycblt.auxio.list.recycler.DividerViewHolder +import org.oxycblt.auxio.list.recycler.GenreViewHolder +import org.oxycblt.auxio.list.recycler.PlaylistViewHolder +import org.oxycblt.auxio.list.recycler.SongViewHolder +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD /** diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index ee83b5418..a4471ae5e 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -29,11 +29,14 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Name +import org.oxycblt.auxio.util.logD /** * Implements the fuzzy-ish searching algorithm used in the search view. * * @author Alexander Capehart + * + * TODO: Handle locale changes */ interface SearchEngine { /** @@ -65,8 +68,9 @@ interface SearchEngine { class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : SearchEngine { - override suspend fun search(items: SearchEngine.Items, query: String) = - SearchEngine.Items( + override suspend fun search(items: SearchEngine.Items, query: String): SearchEngine.Items { + logD("Launching search for $query") + return SearchEngine.Items( songs = items.songs?.searchListImpl(query) { q, song -> song.path.name.contains(q, ignoreCase = true) @@ -75,6 +79,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte artists = items.artists?.searchListImpl(query), genres = items.genres?.searchListImpl(query), playlists = items.playlists?.searchListImpl(query)) + } /** * Search a given [Music] list. diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index a7b29b204..0839d12fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -39,10 +39,23 @@ import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.selection.SelectionViewModel -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.collect +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.setFullWidthLookup /** * The [ListFragment] providing search functionality for the music library. @@ -102,6 +115,7 @@ class SearchFragment : ListFragment() { if (!launchedKeyboard) { // Auto-open the keyboard when this view is shown + this@SearchFragment.logD("Keyboard is not shown yet") showKeyboard(this) launchedKeyboard = true } @@ -142,6 +156,7 @@ class SearchFragment : ListFragment() { if (item.itemId != R.id.submenu_filtering) { // Is a change in filter mode and not just a junk submenu click, update // the filtering within SearchViewModel. + logD("Filter mode selected") item.isChecked = true searchModel.setFilterOptionId(item.itemId) return true @@ -176,6 +191,7 @@ class SearchFragment : ListFragment() { // I would make it so that the position is only scrolled back to the top when // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. + logD("Update finished, scrolling to top") binding.searchRecycler.scrollToPosition(0) } } @@ -220,6 +236,7 @@ class SearchFragment : ListFragment() { * @param view The [View] to focus the keyboard on. */ private fun showKeyboard(view: View) { + logD("Launching keyboard") view.apply { requestFocus() postDelayed(200) { @@ -231,6 +248,7 @@ class SearchFragment : ListFragment() { /** Safely hide the keyboard from this view. */ private fun hideKeyboard() { + logD("Hiding keyboard") requireNotNull(imm) { "InputMethodManager was not available" } .hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS) } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index ec42ca3cb..8a3aa5a1c 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -33,7 +33,9 @@ import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.PlaybackSettings @@ -76,6 +78,7 @@ constructor( override fun onMusicChanges(changes: MusicRepository.Changes) { if (changes.deviceLibrary || changes.userLibrary) { + logD("Music changed, re-searching library") search(lastQuery) } } @@ -94,14 +97,13 @@ constructor( val deviceLibrary = musicRepository.deviceLibrary val userLibrary = musicRepository.userLibrary if (query.isNullOrEmpty() || deviceLibrary == null || userLibrary == null) { - logD("Search query is not applicable.") + logD("Cannot search for the current query, aborting") _searchResults.value = listOf() return } - logD("Searching music library for $query") - // Searching is time-consuming, so do it in the background. + logD("Searching music library for $query") currentSearchJob = viewModelScope.launch { _searchResults.value = @@ -119,6 +121,7 @@ constructor( val items = if (filterMode == null) { // A nulled filter mode means to not filter anything. + logD("No filter mode specified, using entire library") SearchEngine.Items( deviceLibrary.songs, deviceLibrary.albums, @@ -126,6 +129,7 @@ constructor( deviceLibrary.genres, userLibrary.playlists) } else { + logD("Filter mode specified, filtering library") SearchEngine.Items( songs = if (filterMode == MusicMode.SONGS) deviceLibrary.songs else null, albums = if (filterMode == MusicMode.ALBUMS) deviceLibrary.albums else null, @@ -139,11 +143,13 @@ constructor( return buildList { results.artists?.let { + logD("Adding ${it.size} artists to search results") val header = BasicHeader(R.string.lbl_artists) add(header) addAll(SORT.artists(it)) } results.albums?.let { + logD("Adding ${it.size} albums to search results") val header = BasicHeader(R.string.lbl_albums) if (isNotEmpty()) { add(Divider(header)) @@ -153,6 +159,7 @@ constructor( addAll(SORT.albums(it)) } results.playlists?.let { + logD("Adding ${it.size} playlists to search results") val header = BasicHeader(R.string.lbl_playlists) if (isNotEmpty()) { add(Divider(header)) @@ -162,6 +169,7 @@ constructor( addAll(SORT.playlists(it)) } results.genres?.let { + logD("Adding ${it.size} genres to search results") val header = BasicHeader(R.string.lbl_genres) if (isNotEmpty()) { add(Divider(header)) @@ -171,6 +179,7 @@ constructor( addAll(SORT.genres(it)) } results.songs?.let { + logD("Adding ${it.size} songs to search results") val header = BasicHeader(R.string.lbl_songs) if (isNotEmpty()) { add(Divider(header)) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index 0bda7bd9d..8288d7443 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -108,6 +108,7 @@ class AboutFragment : ViewBindingFragment() { // Android 11 seems to now handle the app chooser situations on its own now // [along with adding a new permission that breaks the old manual code], so // we just do a typical activity launch. + logD("Using API 30+ chooser") try { context.startActivity(browserIntent) } catch (e: ActivityNotFoundException) { @@ -119,6 +120,7 @@ class AboutFragment : ViewBindingFragment() { // not work in all cases, especially when no default app was set. If that is the // case, we will try to manually handle these cases before we try to launch the // browser. + logD("Resolving browser activity for chooser") @Suppress("DEPRECATION") val pkgName = context.packageManager @@ -128,16 +130,17 @@ class AboutFragment : ViewBindingFragment() { if (pkgName != null) { if (pkgName == "android") { // No default browser [Must open app chooser, may not be supported] + logD("No default browser found") openAppChooser(browserIntent) - } else - try { - browserIntent.setPackage(pkgName) - startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // Not a browser but an app chooser - browserIntent.setPackage(null) - openAppChooser(browserIntent) - } + } else logD("Opening browser intent") + try { + browserIntent.setPackage(pkgName) + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // Not a browser but an app chooser + browserIntent.setPackage(null) + openAppChooser(browserIntent) + } } else { // No app installed to open the link context.showToast(R.string.err_no_app) @@ -151,6 +154,7 @@ class AboutFragment : ViewBindingFragment() { * @param intent The [Intent] to show an app chooser for. */ private fun openAppChooser(intent: Intent) { + logD("Opening app chooser for ${intent.action}") val chooserIntent = Intent(Intent.ACTION_CHOOSER) .putExtra(Intent.EXTRA_INTENT, intent) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt index f739f6b29..14065ea47 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt @@ -107,9 +107,10 @@ abstract class BasePreferenceFragment(@XmlRes private val screen: Int) : when (preference) { is IntListPreference -> { // Copy the built-in preference dialog launching code into our project so - // we can automatically use the provided preference class. + // we can automatically use the provided preference class. The deprecated code + // is largely unavoidable. val dialog = IntListPreferenceDialog.from(preference) - dialog.setTargetFragment(this, 0) + @Suppress("DEPRECATION") dialog.setTargetFragment(this, 0) dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG) } is WrappedDialogPreference -> { @@ -128,6 +129,7 @@ abstract class BasePreferenceFragment(@XmlRes private val screen: Int) : } if (preference is PreferenceCategory) { + // Recurse into preference children to make sure they are set up as well preference.children.forEach(::setupPreference) return } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt index 0467abcda..fe2bf0066 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -30,6 +30,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.showToast @@ -64,18 +65,22 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { // do one. when (preference.key) { getString(R.string.set_key_ui) -> { + logD("Navigating to UI preferences") findNavController() .navigateSafe(RootPreferenceFragmentDirections.goToUiPreferences()) } getString(R.string.set_key_personalize) -> { + logD("Navigating to personalization preferences") findNavController() .navigateSafe(RootPreferenceFragmentDirections.goToPersonalizePreferences()) } getString(R.string.set_key_music) -> { + logD("Navigating to music preferences") findNavController() .navigateSafe(RootPreferenceFragmentDirections.goToMusicPreferences()) } getString(R.string.set_key_audio) -> { + logD("Navigating to audio preferences") findNavController() .navigateSafe(RootPreferenceFragmentDirections.goToAudioPreferences()) } @@ -85,6 +90,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { playbackModel.savePlaybackState { saved -> // Use the nullable context, as we could try to show a toast when this // fragment is no longer attached. + logD("Showing saving confirmation") if (saved) { context?.showToast(R.string.lbl_state_saved) } else { @@ -94,6 +100,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { } getString(R.string.set_key_wipe_state) -> { playbackModel.wipePlaybackState { wiped -> + logD("Showing wipe confirmation") if (wiped) { // Use the nullable context, as we could try to show a toast when this // fragment is no longer attached. @@ -105,6 +112,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { } getString(R.string.set_key_restore_state) -> playbackModel.tryRestorePlaybackState { restored -> + logD("Showing restore confirmation") if (restored) { // Use the nullable context, as we could try to show a toast when this // fragment is no longer attached. diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 36594b2de..947362fe7 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -22,6 +22,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.annotation.StringRes import androidx.preference.PreferenceManager +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.unlikelyToBeNull @@ -73,15 +74,19 @@ interface Settings { override fun registerListener(listener: L) { if (this.listener == null) { // Registering a listener when it was null prior, attach the callback. + logD("Registering shared preference listener") sharedPreferences.registerOnSharedPreferenceChangeListener(this) } + logD("Registering listener $listener") this.listener = listener } override fun unregisterListener(listener: L) { if (this.listener !== listener) { logW("Given listener was not the current listener.") + return } + logD("Unregistering listener $listener") this.listener = null // No longer have a listener, detach from the preferences instance. sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) @@ -92,6 +97,7 @@ interface Settings { key: String ) { // FIXME: Settings initialization firing the listener. + logD("Dispatching settings change $key") onSettingChanged(key, unlikelyToBeNull(listener)) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt index 49eda0656..5bcee531f 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt @@ -22,6 +22,7 @@ import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.R import org.oxycblt.auxio.settings.BasePreferenceFragment import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe /** @@ -33,6 +34,7 @@ class AudioPreferenceFragment : BasePreferenceFragment(R.xml.preferences_audio) override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_pre_amp)) { + logD("Navigating to pre-amp dialog") findNavController().navigateSafe(AudioPreferenceFragmentDirections.goToPreAmpDialog()) } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt index 8e60a1b33..b0b59d02b 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -26,6 +26,7 @@ import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.settings.BasePreferenceFragment import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe /** @@ -39,6 +40,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_separators)) { + logD("Navigating to separator dialog") findNavController() .navigateSafe(MusicPreferenceFragmentDirections.goToSeparatorsDialog()) } @@ -46,8 +48,10 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) override fun onSetupPreference(preference: Preference) { if (preference.key == getString(R.string.set_key_cover_mode)) { + logD("Configuring cover mode setting") preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> + logD("Cover mode changed, resetting image memory cache") imageLoader.memoryCache?.clear() true } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt index 8669c52c3..f284e1d69 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt @@ -22,6 +22,7 @@ import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.R import org.oxycblt.auxio.settings.BasePreferenceFragment import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe /** @@ -32,6 +33,7 @@ import org.oxycblt.auxio.util.navigateSafe class PersonalizePreferenceFragment : BasePreferenceFragment(R.xml.preferences_personalize) { override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_home_tabs)) { + logD("Navigating to home tab dialog") findNavController() .navigateSafe(PersonalizePreferenceFragmentDirections.goToTabDialog()) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt index b1105f123..8d7ba5114 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt @@ -28,6 +28,7 @@ import org.oxycblt.auxio.settings.BasePreferenceFragment import org.oxycblt.auxio.settings.ui.WrappedDialogPreference import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe /** @@ -41,6 +42,7 @@ class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) { override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_accent)) { + logD("Navigating to accent dialog") findNavController().navigateSafe(UIPreferenceFragmentDirections.goToAccentDialog()) } } @@ -48,20 +50,25 @@ class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) { override fun onSetupPreference(preference: Preference) { when (preference.key) { getString(R.string.set_key_theme) -> { + logD("Configuring theme setting") preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, value -> + logD("Theme changed, recreating") AppCompatDelegate.setDefaultNightMode(value as Int) true } } getString(R.string.set_key_accent) -> { + logD("Configuring accent setting") preference.summary = getString(uiSettings.accent.name) } getString(R.string.set_key_black_theme) -> { + logD("Configuring black theme setting") preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> val activity = requireActivity() if (activity.isNight) { + logD("Black theme changed in night mode, recreating") activity.recreate() } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt index 10fe7f13e..ff54bcfac 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt @@ -25,8 +25,8 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceGroupAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R import com.google.android.material.divider.BackportMaterialDividerItemDecoration -import org.oxycblt.auxio.R /** * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt index d0cafd9f0..9937d1eac 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt @@ -28,6 +28,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import org.oxycblt.auxio.R import org.oxycblt.auxio.util.getDimen +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.systemGestureInsetsCompat /** @@ -82,6 +83,7 @@ abstract class BaseBottomSheetBehavior(context: Context, attributeSet: val layout = super.onLayoutChild(parent, child, layoutDirection) // Don't repeat redundant initialization. if (!initalized) { + logD("Not initialized, setting up child") child.apply { // Set up compat elevation attributes. These are only shown below API 28. translationZ = context.getDimen(R.dimen.elevation_normal) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt index eb3560271..c8fc0305c 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt @@ -26,6 +26,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import kotlin.math.abs import org.oxycblt.auxio.util.coordinatorLayoutBehavior +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat @@ -60,10 +61,12 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri val behavior = dependency.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { + logD("Not laid out yet, cannot update dependent view") return false } if (consumed != lastConsumed) { + logD("Consumed amount changed, re-applying insets") lastConsumed = consumed val insets = lastInsets diff --git a/app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt index 002f49868..afa60e6fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt @@ -30,6 +30,7 @@ import androidx.core.content.res.ResourcesCompat import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.AppBarLayout import org.oxycblt.auxio.util.coordinatorLayoutBehavior +import org.oxycblt.auxio.util.logD /** * An [AppBarLayout] that resolves two issues with the default implementation: @@ -75,6 +76,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr fun expandWithScrollingRecycler() { setExpanded(true) (findScrollingChild() as? RecyclerView)?.let { + logD("Found RecyclerView, expanding with it") addOnOffsetChangedListener(ExpansionHackListener(it)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt b/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt index 657b5c6ca..137e9abe9 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt @@ -51,6 +51,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr fun setVisible(@IdRes viewId: Int): Boolean { val index = children.indexOfFirst { it.id == viewId } if (index == currentlyVisible) return false + logD("Switching toolbar visibility from $currentlyVisible -> $index") return animateToolbarsVisibility(currentlyVisible, index).also { currentlyVisible = index } } @@ -61,14 +62,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr val targetFromAlpha = 0f val targetToAlpha = 1f val targetDuration = + // Since this view starts with the lowest toolbar index, if (from < to) { + logD("Moving higher, use an entrance animation") context.getInteger(R.integer.anim_fade_enter_duration).toLong() } else { + logD("Moving lower, use an exit animation") context.getInteger(R.integer.anim_fade_exit_duration).toLong() } - logD(targetDuration) - val fromView = getChildAt(from) as Toolbar val toView = getChildAt(to) as Toolbar @@ -80,15 +82,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr if (!isLaidOut) { // Not laid out, just change it immediately while are not shown to the user. // This is an initialization, so we return false despite changing. + logD("Not laid out, immediately updating visibility") setToolbarsAlpha(fromView, toView, targetFromAlpha) return false } - if (fadeThroughAnimator != null) { - fadeThroughAnimator?.cancel() - fadeThroughAnimator = null - } - + logD("Changing toolbar visibility $from -> 0f, $to -> 1f") + fadeThroughAnimator?.cancel() fadeThroughAnimator = ValueAnimator.ofFloat(fromView.alpha, targetFromAlpha).apply { duration = targetDuration @@ -100,7 +100,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } private fun setToolbarsAlpha(from: Toolbar, to: Toolbar, innerAlpha: Float) { - logD("${to.id == R.id.detail_edit_toolbar} ${1 - innerAlpha}") from.apply { alpha = innerAlpha isInvisible = innerAlpha == 0f diff --git a/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt b/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt index c1d1074a9..2586745ae 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt @@ -21,8 +21,8 @@ package org.oxycblt.auxio.ui import android.content.Context import android.util.AttributeSet import androidx.annotation.AttrRes +import com.google.android.material.R import com.google.android.material.button.MaterialButton -import org.oxycblt.auxio.R import org.oxycblt.auxio.util.fixDoubleRipple /** diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt index 13bcbcbf9..dc699498a 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt @@ -96,6 +96,7 @@ class UISettingsImpl @Inject constructor(@ApplicationContext context: Context) : override fun onSettingChanged(key: String, listener: UISettings.Listener) { if (key == getString(R.string.set_key_round_mode)) { + logD("Dispatching round mode setting change") listener.onRoundModeChanged() } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt index 09eb411ef..41138d718 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt @@ -18,11 +18,12 @@ package org.oxycblt.auxio.ui.accent +import android.R as SR import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.R +import com.google.android.material.R as MR import org.oxycblt.auxio.databinding.ItemAccentBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.util.getAttrColorCompat @@ -118,9 +119,9 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin binding.accent.apply { iconTint = if (isSelected) { - context.getAttrColorCompat(R.attr.colorSurface) + context.getAttrColorCompat(MR.attr.colorSurface) } else { - context.getColorCompat(android.R.color.transparent) + context.getColorCompat(SR.color.transparent) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index d4c058077..bf6d9313b 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -23,7 +23,6 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration -import android.os.Build import android.util.TypedValue import android.view.LayoutInflater import android.widget.Toast @@ -184,7 +183,7 @@ fun Context.newMainPendingIntent(): PendingIntent = this, IntegerTable.REQUEST_CODE, Intent(this, MainActivity::class.java), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + PendingIntent.FLAG_IMMUTABLE) /** * Create a [PendingIntent] that will broadcast the specified command when launched. @@ -196,4 +195,4 @@ fun Context.newBroadcastPendingIntent(action: String): PendingIntent = this, IntegerTable.REQUEST_CODE, Intent(action).setFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + PendingIntent.FLAG_IMMUTABLE) 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 71fc880d6..eb24d8093 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -27,6 +27,7 @@ import android.view.WindowInsets import androidx.annotation.RequiresApi import androidx.appcompat.widget.AppCompatButton import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.app.ShareCompat import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat import androidx.navigation.NavController @@ -35,6 +36,8 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import java.lang.IllegalArgumentException +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song /** * Get if this [View] contains the given [PointF], with optional leeway. @@ -263,3 +266,35 @@ fun WindowInsets.replaceSystemBarInsetsCompat( } } } + +/** + * Share a single [Song]. + * + * @param song + */ +fun Context.share(song: Song) = share(listOf(song)) + +/** + * Share all songs in a [MusicParent]. + * + * @param parent The [MusicParent] to share. + */ +fun Context.share(parent: MusicParent) = share(parent.songs) + +/** + * Share an arbitrary list of [Song]s. + * + * @param songs The [Song]s to share. + */ +fun Context.share(songs: List) { + if (songs.isEmpty()) return + logD("Showing sharesheet for ${songs.size} songs") + val builder = ShareCompat.IntentBuilder(this) + val mimeTypes = mutableSetOf() + for (song in songs) { + builder.addStream(song.uri) + mimeTypes.add(song.mimeType.fromFormat ?: song.mimeType.fromExtension) + } + + builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser() +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 6c1e9e91b..1cb3f599c 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -22,13 +22,12 @@ import android.content.Context import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest -import coil.transform.RoundedCornersTransformation import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.image.extractor.SquareFrameTransform +import org.oxycblt.auxio.image.RoundedCornersTransformation import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.queue.Queue @@ -76,6 +75,7 @@ constructor( val repeatMode = playbackManager.repeatMode val isShuffled = playbackManager.queue.isShuffled + logD("Updating widget with new playback state") bitmapProvider.load( song, object : BitmapProvider.Target { @@ -83,12 +83,15 @@ constructor( val cornerRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12, always round the cover with the widget's inner radius + logD("Using android 12 corner radius") context.getDimenPixels(android.R.dimen.system_app_widget_inner_radius) } else if (uiSettings.roundMode) { // < Android 12, but the user still enabled round mode. + logD("Using default corner radius") context.getDimenPixels(R.dimen.size_corners_medium) } else { // User did not enable round mode. + logD("Using no corner radius") 0 } @@ -97,9 +100,7 @@ constructor( // rounded corners. builder .size(getSafeRemoteViewsImageSize(context, 10f)) - .transformations( - SquareFrameTransform.INSTANCE, - RoundedCornersTransformation(cornerRadius.toFloat())) + .transformations(RoundedCornersTransformation(cornerRadius.toFloat())) } else { builder.size(getSafeRemoteViewsImageSize(context)) } @@ -107,6 +108,7 @@ constructor( override fun onCompleted(bitmap: Bitmap?) { val state = PlaybackState(song, bitmap, isPlaying, repeatMode, isShuffled) + logD("Bitmap loaded, uploading state $state") widgetProvider.update(context, uiSettings, state) } }) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 88e3dffa0..153b7bccf 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -34,7 +34,9 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.util.* +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.newBroadcastPendingIntent /** * The [AppWidgetProvider] for the "Now Playing" widget. This widget shows the current playback @@ -79,6 +81,7 @@ class WidgetProvider : AppWidgetProvider() { fun update(context: Context, uiSettings: UISettings, state: WidgetComponent.PlaybackState?) { if (state == null) { // No state, use the default widget. + logD("No state provided, returning to default") reset(context) return } @@ -99,6 +102,7 @@ class WidgetProvider : AppWidgetProvider() { val component = ComponentName(context, this::class.java) try { awm.updateAppWidgetCompat(context, component, views) + logD("Successfully updated RemoteViews layout") } catch (e: Exception) { // Layout update failed, gracefully degrade to the default widget. logW("Unable to update widget: $e") diff --git a/app/src/main/res/drawable-v23/ui_item_ripple.xml b/app/src/main/res/drawable-v23/ui_item_ripple.xml deleted file mode 100644 index 8f0d43cfb..000000000 --- a/app/src/main/res/drawable-v23/ui_item_ripple.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_item_bg.xml b/app/src/main/res/drawable/ui_item_bg.xml index fb0a9dec3..a8a60879f 100644 --- a/app/src/main/res/drawable/ui_item_bg.xml +++ b/app/src/main/res/drawable/ui_item_bg.xml @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_queue_drag_handle.xml b/app/src/main/res/drawable/ui_queue_drag_handle.xml deleted file mode 100644 index 2c907c3d6..000000000 --- a/app/src/main/res/drawable/ui_queue_drag_handle.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index a7406768f..f2919eacc 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,15 +16,16 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false" + tools:ignore="ContentDescription" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false" + tools:ignore="ContentDescription" /> - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - + tools:ignore="ContentDescription" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false" + tools:ignore="ContentDescription" /> - + android:layout_height="wrap_content" + app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/fragment_playback_bar.xml b/app/src/main/res/layout/fragment_playback_bar.xml index 4e9e2cd5a..eb95d8968 100644 --- a/app/src/main/res/layout/fragment_playback_bar.xml +++ b/app/src/main/res/layout/fragment_playback_bar.xml @@ -6,14 +6,15 @@ android:layout_height="wrap_content" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintVertical_chainStyle="packed" /> diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 2505dcf32..3d495dd40 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -16,15 +16,25 @@ with us only overlaying the track number (and other elements) onto it. --> - + app:layout_constraintTop_toTopOf="parent"> + + - + @@ -68,9 +78,9 @@ android:layout_height="wrap_content" android:layout_marginEnd="@dimen/spacing_mid_medium" android:textColor="?android:attr/textColorSecondary" - app:layout_constraintBottom_toBottomOf="@+id/song_track_bg" + app:layout_constraintBottom_toBottomOf="@+id/song_track_cover" app:layout_constraintEnd_toStartOf="@+id/song_menu" - app:layout_constraintStart_toEndOf="@+id/song_track_bg" + app:layout_constraintStart_toEndOf="@+id/song_track_cover" app:layout_constraintTop_toBottomOf="@+id/song_name" tools:text="16:16" /> diff --git a/app/src/main/res/layout/item_detail_header.xml b/app/src/main/res/layout/item_detail_header.xml index 99c4e17d2..56c756f38 100644 --- a/app/src/main/res/layout/item_detail_header.xml +++ b/app/src/main/res/layout/item_detail_header.xml @@ -10,14 +10,15 @@ android:paddingEnd="@dimen/spacing_medium" android:paddingBottom="@dimen/spacing_mid_medium"> - + tools:ignore="ContentDescription" /> - + tools:ignore="ContentDescription"> + + + + @@ -43,7 +55,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" tools:visibility="gone" - app:layout_constraintStart_toEndOf="@+id/disc_icon" + app:layout_constraintStart_toEndOf="@+id/disc_cover" app:layout_constraintTop_toBottomOf="@+id/disc_number" tools:text="Part 1" /> diff --git a/app/src/main/res/layout/item_editable_song.xml b/app/src/main/res/layout/item_editable_song.xml index 93fe6f0de..dbda5a44a 100644 --- a/app/src/main/res/layout/item_editable_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -34,7 +34,7 @@ android:layout_height="wrap_content" android:background="@drawable/ui_item_ripple"> - + app:layout_constraintTop_toTopOf="parent" /> - + app:enablePlaybackIndicator="false" + app:enableSelectionBadge="false"> + + + + - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="parent" /> - + app:layout_constraintTop_toTopOf="parent" /> + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_detail.xml b/app/src/main/res/menu/menu_album_detail.xml index 34de6eb5e..7cc2b4b79 100644 --- a/app/src/main/res/menu/menu_album_detail.xml +++ b/app/src/main/res/menu/menu_album_detail.xml @@ -12,4 +12,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_song_actions.xml b/app/src/main/res/menu/menu_album_song_actions.xml index 256322f3e..7325144c0 100644 --- a/app/src/main/res/menu/menu_album_song_actions.xml +++ b/app/src/main/res/menu/menu_album_song_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_album_actions.xml b/app/src/main/res/menu/menu_artist_album_actions.xml index c94d6886f..a39b0127e 100644 --- a/app/src/main/res/menu/menu_artist_album_actions.xml +++ b/app/src/main/res/menu/menu_artist_album_actions.xml @@ -18,4 +18,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_song_actions.xml b/app/src/main/res/menu/menu_artist_song_actions.xml index 4b20abd21..78442df43 100644 --- a/app/src/main/res/menu/menu_artist_song_actions.xml +++ b/app/src/main/res/menu/menu_artist_song_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_parent_actions.xml b/app/src/main/res/menu/menu_parent_actions.xml index 4e6112035..6de2527de 100644 --- a/app/src/main/res/menu/menu_parent_actions.xml +++ b/app/src/main/res/menu/menu_parent_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_parent_detail.xml b/app/src/main/res/menu/menu_parent_detail.xml index 3a2225ea3..e73829b41 100644 --- a/app/src/main/res/menu/menu_parent_detail.xml +++ b/app/src/main/res/menu/menu_parent_detail.xml @@ -9,4 +9,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playback.xml b/app/src/main/res/menu/menu_playback.xml index 0c24bb5da..92264d881 100644 --- a/app/src/main/res/menu/menu_playback.xml +++ b/app/src/main/res/menu/menu_playback.xml @@ -21,4 +21,7 @@ android:id="@+id/action_song_detail" android:title="@string/lbl_song_detail" app:showAsAction="never" /> + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml index 395ec387b..6a165da6a 100644 --- a/app/src/main/res/menu/menu_playlist_actions.xml +++ b/app/src/main/res/menu/menu_playlist_actions.xml @@ -18,4 +18,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/menu_playlist_detail.xml index 05a11b388..666629234 100644 --- a/app/src/main/res/menu/menu_playlist_detail.xml +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -12,4 +12,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_song_actions.xml b/app/src/main/res/menu/menu_playlist_song_actions.xml index 28c508681..e55d8e3f6 100644 --- a/app/src/main/res/menu/menu_playlist_song_actions.xml +++ b/app/src/main/res/menu/menu_playlist_song_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/menu_selection_actions.xml index 568d04a62..e596b97a6 100644 --- a/app/src/main/res/menu/menu_selection_actions.xml +++ b/app/src/main/res/menu/menu_selection_actions.xml @@ -21,4 +21,8 @@ android:id="@+id/action_selection_shuffle" android:title="@string/lbl_shuffle_selected" app:showAsAction="never"/> + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_song_actions.xml b/app/src/main/res/menu/menu_song_actions.xml index abb176fb5..b892ba7c3 100644 --- a/app/src/main/res/menu/menu_song_actions.xml +++ b/app/src/main/res/menu/menu_song_actions.xml @@ -18,4 +18,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 833d8340e..561b05892 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -40,7 +40,7 @@ Зборнікі Зборнік Жывая зборка - Міксы + DJ Міксы Светлая Жывы сінгл Рэмікс сінгла @@ -48,7 +48,7 @@ Саўндтрэкі Мікстэйпы Мікстэйп - Мікс + DJ Мікс Цёмная Канцэрт Выканаўца @@ -281,4 +281,16 @@ Плэйліст створаны Паведамленні ў плэйліст Без трэкаў + Выдаліць + Выдаліць %s\? Гэтае дзеянне не можа быць адменена. + Перайменаваць + Перайменаваць плэйліст + Выдаліць плэйліст\? + Змяніць + Плэйліст перайменаван + Плэйліст выдален + Падзяліцца + Няма дыска + З\'яўляецца на + Рэдагаванне %s \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 900a8e440..956f74699 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -238,9 +238,9 @@ Středník (;) Lomítko (/) Remixová kompilace - Mixy + DJ mixy Živá kompilace - Mix + DJ mix Varování: Při použití tohoto nastavení mohou být některé značky nesprávně interpretovány jako vícehodnotové. Tento problém můžete vyřešit přidáním zpětného lomítka (\\) před nechtěné oddělovací znaky. Vyloučit nehudební obsah Ignorovat zvukové soubory, které nejsou hudbou, například podcasty @@ -299,4 +299,9 @@ Seznam skladeb přejmenován Seznam skladeb odstraněn Přejmenovat seznam skladeb + Upravit + Také v + Žádný disk + Úprava seznamu %s + Sdílet \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 72c42e4a7..e70e04dfd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -224,8 +224,8 @@ Wiedergabe anhalten Live-Kompilation Remix-Kompilation - Mixes - Mix + DJ-Mixes + DJ-Mix Mehrfachwert-Trenner Zeichen ändern, welche mehrere Tag-Werte trennt Und-Zeichen (&) @@ -290,4 +290,9 @@ Wiedergabeliste umbenennen Wiedergabeliste umbenannt Wiedergabeliste gelöscht + Bearbeiten + Teilen + Keine Disc + Erscheint in + %s bearbeiten \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index de9f77187..3a7e94380 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -229,8 +229,8 @@ Barra oblicua (/) Recopilación en directo Compilaciones de remezclas - Mezclas - Mezcla + Mezclas del DJ + Mezclas del DJ Ecualizador Portadas de álbumes Apagado @@ -295,4 +295,8 @@ ¿Borrar %s\? Esto no se puede deshacer. ¿Borrar la lista de reproducción\? Editar + Editando %s + Aparece en + Compartir + Sin disco \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 060647abe..8ade1b2d6 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2,70 +2,296 @@ Újra - Engedélyezés - Műfajo + Engedélyez + Műfajok Előadók Albumok Dalok - Összes Dalok + Összes dal Keresés - Filter + Szűrő Összes - Összes + Rendezés Növekvő Lejátszás Keverés - Most Játszott - Lejátszási sor - Lejátszás következőnek - Lejátszás sorhoz adás + Most játszott + Várósor + Következő lejátszása + Várósorhoz ad Sorbaállítva - Ugrás az előadóhoz - Ugrás az albumhoz - Névjegy + Ugrás előadóhoz + Ugrás albumhoz + Rólunk Verzió - Megtekintés GitHubon - Engedélyek + Forráskód + Licencek Beállítások - Megjelenés + Megjelenés és érzés Téma Automatikus Világos Sötét - Kiemelés + Színséma Hang - Működés + Testreszabás Nem található zene Keresés a könyvtárban… Sáv %d - Lejátszás/Szünet + Lejátszás/szünet - Piros - Rózsaszínű - Lila - Sötétlila + Vörös + Pink + Bíbor + Mély bíbor Indigókék Kék - Világoskék + Sötétkék Kékeszöld Zöld - Világoszöld - Sárgazöld + Sötétzöld + Lime Sárga Narancs Barna Szürke - %d Dal - %d Dalok + %d dal + %d dal - %d Album - %d Albumok + %d album + %d album + EP-k + EP + Élő EP + Remix EP + Név + Dátum + Csökkenő + Kiválasztott lejátszása + Új lejátszólista + Ismeretlen műfaj + Ugrás a következő dalra + Ugrás az utolsó dalra + Ismétlő mód módosítása + Keverés be/ki kapcsolása + %s album borítója + Visszajátszás + Szülő útvonal + Mappa eltávolítása + Playlistához ad + Formátum + Wiki + OK + Méret + Bitráta + Figyelem: Ennek a beállításnak a használata azt eredményezheti, hogy egyes címkék helytelenül több értéket tartalmaznak. Ezt úgy oldhatja fel, hogy a nem kívánt elválasztó karakterek elé egy backslash-t (\\) helyez. + Szünet, amikor egy dal ismétlődik + Visszajátszás stratégia + Inkább album + Inkább hangsáv + Ez a mappa nem támogatott + %s playlista képe + Nincs hangsáv + Az aktuális lejátszási állapot mentése most + Nincs dal + MPEG-1 hang + MPEG-4 hang + %s műfaj képe + %d Hz + Többértékű elválasztók + Betöltött album: %d + Élő összeállítás + Remix összeállítás + Gyűjtemények + DJ Mixek + DJ Mix + Műfaj + Dal tulajdonságai + Nincs mappa + Tiszta fekete sötét téma használata + Dinamikus + Album borítók + Visszatekerés az előző dalra való ugrás előtt + Figyelem: Az előerősítő magas pozitív értékre módosítása egyes hangsávoknál csúcsosodást eredményezhet. + Könyvtár + Kitartás + Lejátszólista + Lejátszólisták + Töröl + Zenelejátszás megtekintése és vezérlése + Zenei könyvtár betöltése… + Playlistához adva + Visszatekerés visszaugrás előtt + Ismétlés szünet + %s törlése\? Ez nem fordítható vissza. + Hangsáv + Új dal lejátszásakor a keverési mód bekapcsolva tartása + A számokkal vagy \"the\" típusú szavakkal kezdődő nevek helyes rendezése (legjobban angol nyelvű zenékkel működik) + Az összes keverése + A korábban elmentett lejátszási állapot visszaállítása (ha van ilyen) + Visszajátszás előerősítő + Az előerősítő a lejátszás során a létező beállítással kerül alkalmazásra + Helyezze át ezt a dalt + %s előadó fotója + Teljes időtartam: %s + Kiválasztottak keverése + UI vezérlők és viselkedés testreszabása + A könyvtárfülek láthatóságának és sorrendjének módosítása + A tétel részleteiből történő lejátszáskor + Lejátszás albumból + A zene és a képek betöltésének vezérlése + Képek + Időtartam + Dal szám + Tulajdonságok + Mintavétel + Zenekönyvtár változás figyelése… + Az app téma és színeinek módosítása + Lekerekítés + Ismétlő mód + Viselkedés + Emlékezzen a keverésre + Inkább album, ha egyet játszik + Zene frissítése + Mégse + Fájl név + Egyéni lejátszási sáv művelet + A lejátszás mindig akkor indul el, ha a fejhallgató csatlakoztatva van (nem minden eszközön működik) + Automatikus újratöltés + Cián + Lejátszólista átnevezés + Átnevez + Hozzáadás dátuma + Mappák + Ment + Alaphelyzet + Állapot törölve + Fejlesztő Alexander Capehart + Lejátszás az összes dalból + Lejátszás műfajból + Tartalom + A zenei könyvtár újratöltése, ha változik (állandó értesítést igényel) + Zene könyvtárak + A zene nem töltődik be a hozzáadott mappákból. + Kizárva + A zene betöltése sikertelen + Plusz (+) + Nem találtunk olyan alkalmazást, amely képes lenne kezelni ezt a feladatot + Állapot helyreállítás nem lehetséges + Ismeretlen előadó + %1$s, %2$s + Egy új playlista készítése + Várósor megnyitás + Mozgassa ezt a lapot + Keresési lekérdezés törlése + Nincs zenelejátszás + Egyéni értesítési művelet + Nincs dátum + Gyors + A nem zenei anyagok kizárása + Állapot törlés nem lehetséges + Állapot mentés nem lehetséges + Keverés minden dalból + Ogg hang + Megjelenítés + Hangsáv + Szerkeszt + Lemez + Playlista létrehozva + Fekete téma + Lekerekített sarkok engedélyezése további UI elemeken (az albumborítók lekerekítése szükséges) + Könyvtár fülek + Mód + Free Lossless Audio Codec (FLAC) + Beállítás címkékkel + A könyvtárból történő lejátszáskor + Lejátszás a megjelenő elemről + %d kbps + Betöltött műfaj: %d + Zene betöltés + Zene betöltése + Zene könyvtár figyelése + Állapot mentve + Lejátszás megállítása + Egyszerű, racionális zene lejátszó androidra. + Matroska hang + Album + Kislemezek + Kislemez + Élő album + Összeállítások + Album remix + Összeállítás + Hangsáv + Élő kislemez + Remixelt kislemez + Gyűjtemény + Élő + Keverés + Előadó + Remixek + Hozzáad + +%.1f dB + Per jel (/) + + %d előadó + %d előadó + + Equalizer + Könyvtári statisztika + Playlista átnevezve + Playlista törölve + Ugrás a következőre + Lejátszás előadótól + Zene + A nem zenei fájlok, például podcastok figyelmen kívül hagyása + Több címkeértéket jelölő karakterek konfigurálása + Vessző (,) + Ponosvessző (;) + És (&) + Intelligens rendezés + Közreműködők elrejtése + Csak az albumon közvetlenül feltüntetett előadók megjelenítése (a jól címkézett könyvtárakban működik a legjobban) + Ki + Magas minőség + Hang és lejátszási viselkedés konfigurálása + Lejátszás + Fejhallgató auto. lejátszás + Beállítás címkék nélkül + Kezelje, hogy honnan töltsön be zenét + Zene újraolvasása + A címkék gyorsítótárának törlése és a zenei könyvtár teljes újratöltése (lassabb, de teljesebb) + Lejátszási állapot mentése + Lejátszási állapot törlése + A zene csak az Ön által hozzáadott mappákból töltődik be. + A zenei könyvtár újratöltése, lehetőség szerint a gyorstárazott címkék használatával + %d kiválasztott + %d lemez + %d playlista + -%.1f dB + Zenei könyvtár betöltése… (%1$d/%2$d) + Betöltött dal: %d + Betöltött előadó: %d + Album borító + Advanced Audio Coding (AAC) + Megjelenik itt, + Megoszt + Lejátszólista törlése\? + Állapot helyreállítva + A korábban elmentett lejátszási állapot törlése (ha van ilyen) + Lejátszási állapot visszaállítása + Tartalmaz + Az Auxio engedélyt kér a zenei könyvtár olvasásához + Távolítsa el ezt a dalt + Auxio ikon + Nincs lemez + %s szerkesztése \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 19d059cd1..964385c34 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -63,7 +63,7 @@ Comincia la riproduzione ogni volta che le cuffie sono inserite (potrebbe non funzionare su tutti i dispositivi) Strategia ReplayGain Preferisci traccia - Preferisci disco + Preferisci album Preferisci l\'album se in riproduzione Comportamento Quando in riproduzione dalla libreria @@ -85,7 +85,7 @@ Nessuna app può completare questa azione Questa cartella non è supportata - Cerca nella tua libreria… + Cerca nella libreria… Canzone %d Riproduci o pausa @@ -109,7 +109,7 @@ Genere sconosciuto Data sconosciuta Nessuna traccia - Nessuna canzone riproduzione + Nessuna canzone in riproduzione Rosso Rosa @@ -156,10 +156,10 @@ Caricamento libreria musicale… (%1$d/%2$d) Quando in riproduzione dai dettagli dell\'elemento Attenzione: impostare valore positivi alti può provocare distorsioni su alcune tracce. - Regolazione senza tags + Regolazione senza tag Mescola Mescola tutto - Regolazione con tags + Regolazione con tag Il pre-amp è applicato alla regolazione esistente durante la riproduzione Riproduci dall\'elemento mostrato Gestisci le cartelle da dove caricare la musica @@ -185,7 +185,7 @@ Directory superiore Formato Dimensione - Velocità di trasmissione + Bitrate Cancella Pre-amp ReplayGain Sto monitorando i cambiamenti nella tua libreria musicale… @@ -277,8 +277,8 @@ Discendente Playlist Playlist - Ignora gli articoli durante l\'ordinamento - Ignora parole come \"the\" durante l\'ordinamento per nome (funziona meglio con la musica in lingua inglese) + Ordinazione intelligente + Ordina correttamente i nomi che iniziano con numeri o parole come \"the\" (funziona meglio con i titoli in inglese) Crea una nuova playlist Immagine della playlist per %s Nuova playlist @@ -287,4 +287,12 @@ Aggiunto alla playlist Niente canzoni Playlist %d + Elimina + Eliminare la playlist\? + Rinomina + Rinomina playlist + Modifica + Eliminare %s\? L\'operazione non può essere annullata. + Playlist eliminata + Playlist rinominata \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index cd948d6b6..1e1db4996 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,9 +1,9 @@ - מוזיקה נטענת - מוזיקה נטענת - נסה~י שוב - מפקח על ספריית המוזיקה + מוזיקה בטעינה + מוזיקה בטעינה + לנסות שוב + מתבצעת סריקה בספריית המוזיקה שלך כל השירים אלבומים אלבום חי @@ -26,8 +26,8 @@ רמיקסים אומן אומנים - ז\'אנר - ז\'אנרים + סוגה + סוגות סינון הכל תאריך @@ -37,87 +37,87 @@ מיון עולה יורד - מנוגן כעת + מושמע כעת איקוולייזר - נגנ~י - נגנ~י נבחרים + ניגון + ניגון הנבחרים ערבוב - ערבוב נבחרים - נגנ~י את הבא - הוספ~י לתור + ערבוב הנבחרים + ניגון הבא + הוספה לתור מעבר לאלבום הצגת מאפיינים מאפייני שיר - פורמט + תבנית גודל - קצב סיביות (ביטרייט) - קצב דגימה (סאמפל רייט) - ערבב~י הכל + קצב סיביות + קצב דגימה + ערבוב הכול אישור ביטול שמירה אתחול - הוספ~י - המצב שנשמר + הוספה + המצב נשמר גרסה קוד מקור ויקי - רשיונות + רישיונות סטטיסטיקות ספרייה צפייה ושליטה בהשמעת המוזיקה טוען את ספריית המוזיקה שלך… - משגיח על ספריית המוזיקה שלך כדי לאתר שינויים… - התווסף לרשימה + סורק את ספריית המוזיקה שלך כדי לאתר שינויים… + התווסף לתור מפותח על ידי אלכסנדר קייפהארט - חפש~י בספרייה שלך… + חיפוש בספרייה שלך… מראה ותחושה - שנה~י את ערכת הנושא והצבעים של היישום + שינוי ערכת הנושא והצבעים של היישום ערכת נושא בהיר כהה - סכמת צבעים + ערכת צבעים ערכת נושא שחורה - השתמש~י בערכת נושא שחורה לגמרי - מצב עגול + שימוש בערכת נושא שחורה לגמרי + מצב מעוגל התאמה אישית - התאמ~י את בקרי והתנהגות הממשק - צג + התאמת רכיבים והתנהגות ממשק המשתמש + תצוגה לשוניות ספרייה פעולת התראות מותאמת אישית - דלג~י לבא + דילוג לבא מצב חזרה התנהגות כאשר מנוגן מהספרייה כאשר מנוגן מפרטי הפריט - נגנ~י מהפריט המוצג - נגנ~י מכל השירים - נגנ~י מאלבום - נגנ~י מהאומן - נגנ~י מז\'אנר - זכור~י ערבוב - שמור~י על ערבוב פועל בעת הפעלת שיר חדש + ניגון מהפריט המוצג + ניגון מכל השירים + ניגון מאלבום + ניגון מהאומן + ניגון מסוגה + לזכור ערבוב + המשך ערבוב בעת הפעלת שיר חדש תוכן טעינה מחדש אוטומטית - טענ~י את הספריה מחדש בכל פעם שהיא משתנה (דורש התראה קבועה) - התעלמ~י מקבצי אודיו שאינם מוזיקה, כמו פודקאסטים (הסכתים) + לטעון מחדש את הספרייה בכל פעם שהיא משתנה (דורש התראה קבועה) + התעלמות מקובצי שמע שאינם מוזיקה, כמו הסכתים מפרידים רבי-ערכים פסיק (,) נקודה-פסיק (;) פלוס (+) - ו- (&) - החבא~י משתפי~ות פעולה - הראה~י רק אומנים שמצויינים ישירות בקרדיטים של אלבום (עובד באופן הטוב ביותר על ספריות מתוייגות היטב) - עטיפות אלבומים + גם (&) + הסתרת שיתופי פעולה + הצגת אומנים שמצויינים ישירות בקרדיטים של אלבום בלבד (עובד באופן מיטבי על ספריות מתויגות היטב) + עטיפות אלבום כבוי מהיר - אודיו - השמעה + שמע + ניגון ניגון אוטומטי באוזניות הרצה לאחור לפני דילוג אחורה - הריצ~י לאחור לפני דילוג לשיר הקודם + הרצה לאחור לפני דילוג לשיר הקודם עצירה בעת חזרה - עוצמת נגינה מחדש - העדפ~י אלבום + ReplayGain + העדפת אלבום מגבר עוצמת נגינה מחדש התאמה עם תגיות מיקסטייפ @@ -128,35 +128,136 @@ סינגל רמיקס מיקסים חיפוש - אורך + משך שם רצועה תור מעבר לאומן שם קובץ - ערבב~י - מצב שוחזר - אודות + ערבוב + המצב שוחזר + על אודות הגדרות אוטומטי - הפעל~י פינות מעוגלות ברכיבי ממשק נוספים (עטיפות אלבומים נדרשות להיות מעוגלות) - שנה~י את הנראות והסדר של לשוניות הספרייה + הפעלת פינות מעוגלות ברכיבי ממשק נוספים (עטיפות אלבומים נדרשות להיות מעוגלות) + שינוי מראה וסדר לשוניות הספרייה פעולת סרגל השמעה מותאמת אישית - קבע~י איך מוזיקה ותמונות נטענים + הגדרת טעינת המוזיקה והתמונות מוזיקה אי-הכללת תוכן שאינו מוזיקה - התאמ~י תווים המציינים ערכי תגית מרובים + התאמת תווים המציינים ערכי תגית מרובים קו נטוי (/) אזהרה: השימוש בהגדרה זו עלול לגרום לחלק מהתגיות להיות מפורשות באופן שגוי כבעלות מספר ערכים. ניתן לפתור זאת על ידי הכנסת קו נטוי אחורי (\\) לפני תווים מפרידים לא רצויים. איכות גבוהה - התעלמ~י ממילים כמו \"The\" (\"ה-\") בעת סידור על פי שם (עובד באופן הכי טוב עם מוזיקה בשפה האנגלית) + התעלמות ממילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית) תמונות - התאמ~י התנהגות צליל והשמעה - התחל~י לנגן תמיד ברגע שמחוברות אוזניות (עלול לא לעבוד בכל המערכות) - עצר~י כאשר שיר חוזר - העדפ~י רצועה - אסטרטגיית עוצמת נגינה מחדש - העדפ~י אלבום אם אחד מופעל + הגדרת הצליל והניגון + תמיד להתחיל לנגן ברגע שמחוברות אזניות (עלול לא לעבוד בכל המערכות) + השהיה עם חזרה על שיר + העדפת רצועה + אסטרטגיית ReplayGain + העדפת אלבום אם אחד מופעל התאמה ללא תגיות המגבר מוחל על ההתאמה הקיימת בזמן השמעה + רשימת השמעה חדשה + הוספה לרשימת השמעה + לתת + רשימת השמעה + רשימות השמעה + מחיקה + שינוי שם + שינוי שם רשימת השמעה + למחוק את רשימת ההשמעה\? + עריכה + לא ניתן לנקות את המצב + כתום + תיקיות מוזיקה + טעינה מחדש של ספריית המוזיקה, במידה וניתן יעשה שימוש במטמון תגיות + סריקה מחדש אחר מוזיקה + שמירת מצב הנגינה + לא ניתן לשמור את המצב + ‏ Auxio צריך הרשאות על מנת לקרוא את ספריית המוזיקה שלך + פתיחת התור + סך הכל משך: %s + רשימת השמעה %d + אומנים טעונים: %d + שירים טעונים: %d + אלבומים טעונים: %d + סוגות טעונות: %d + המצב נוקה + ספרייה + שמירת מצב הנגינה הנוכחי כעת + לא נמצא יישום שיכול לטפל במשימה זו + אין תיקיות + תיקייה זו אינה נתמכת + דילוג לשיר האחרון + שינוי מצב חזרה + ניגון או השהיה + דילוג לשיר הבאה + הפעלת או כיבוי של מצב ערבוב + עטיפת אלבום עבור %s + תמונת אומן עבור %s + יצירת תמונה עבור %s + אומן לא ידוע + סוגה לא ידועה + אין תאריך + אין רצועה + אך מוזיקה אינה מתנגנת + כחול + כחול עמוק + אפור + דינמי + המוזיקה שלך בטעינה (‎%1$d/%2$d)… + דיסק %d + ניהול תיקיות המוזיקה לטעינה + אין שירים + ורוד + נוצרה רשימת השמעה + תיקיות + + אומן אחד + שני אומנים + %d אומנים + + לכלול + רענון מוזיקה + לא נמצאה מוזיקה + אירע כשל בטעינה מוזיקה + עטיפת אלבום + ניקוי מצב הנגינה + + שיר אחד + שני שירים + %d שירים + + + אלבום אחד + שני אלבומים + %d אלבומים + + שונה שם לרשימת השמעה + רשימת השמעה נמחקה + נוסף לרשימת השמעה + ערבוב כל השירים + סמל Auxio + הסרת תיקייה + תמונת רשימת השמעה עבור %s + אדום + ירוק + ניתוב הורה + לא ניתן לשחזר את המצב + רצועה %d + יצירת רשימת השמעה חדשה + עצירת הנגינה + הסרת שיר זה + שיתוף + מצב + החרגה + העברת שיר זה + העברת לשונית זו + ניקוי תור החיפוש + אין דיסק + ירוק עמוק + צהוב + מחיקת %s\? פעולה זו לא ניתן לביטול. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 62b5683b4..8117c1f3a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -272,4 +272,16 @@ 曲がありません プレイリスト %d 新しいプレイリストを作成する + 消去 + 名前の変更 + プレイリストの名前を変更する + プレイリストを削除しますか\? + プレイリストが削除されました + 編集 + プレイリストの名前が変更されました + %sを削除しますか\? この操作は元に戻すことができません。 + 共有 + ディスクがありません + %sを編集中 + に登場します \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c99c87e16..51108beaa 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -172,10 +172,10 @@ Ogg 오디오 마트로스카 오디오 %d Hz - 믹스 + DJ믹스 라이브 컴필레이션 리믹스 편집 - 믹스 + DJ믹스 이퀄라이저 셔플 표시된 항목에서 재생 @@ -283,4 +283,16 @@ 재생목록에 추가됨 재생목록 %d 노래 없음 + 삭제 + %s를 삭제하시겠습니까\? 이 취소 할 수 없습니다. + 이름 바꾸기 + 재생목록 이름 바꾸기 + 재생목록을 삭제하시겠습니까\? + 편집하다 + 에 나타납니다 + 공유하다 + 재생목록의 이름이 변경됨 + 디스크 없음 + 재생목록이 삭제되었습니다 + %s 수정 중 \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 9cc1f6ec5..6f9d600e2 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -128,20 +128,20 @@ Jokių aplankų Šis aplankas nepalaikomas Groti arba pristabdyti - Pereiti į kitą dainą - Pereiti į paskutinę dainą + Peršokti į kitą dainą + Peršokti į paskutinę dainą Mikstapas Mikstapai Bibliotekos skirtukai Keisti bibliotekos skirtukų matomumą ir tvarką Pageidaujamas albumui Pageidaujamas albumui, jei vienas groja - Jokių programų nerasta, kurios galėtų atlikti šią užduotį - „Auxio“ piktograma - Perkelti šią eilės dainą + Jokią programą nerasta, kuri galėtų atlikti šią užduotį + Auxio piktograma + Perkelti šią dainą Perkelti šį skirtuką Muzikos krovimas nepavyko - „Auxio“ reikia leidimo skaityti jūsų muzikos biblioteką + Auxio reikia leidimo skaityti jūsų muzikos biblioteką Diskas %d +%.1f dB -%.1f dB @@ -173,7 +173,7 @@ Išvalyti paieškos užklausą Muzika nebus įkeliama iš pridėtų aplankų jūs pridėsite. Įtraukti - Pašalinti šią eilės dainą + Pašalinti šią dainą Groti iš visų dainų Groti iš parodyto elemento Groti iš albumo @@ -210,10 +210,10 @@ Keisti kartojimo režimą Indigos %d kbps - Miksai - Miksas + DJ\'ų Miksai + DJ\'o Miksas Gyvai kompiliacija - Remikso kompiliacijos + Remikso kompiliacija Pagrindinis aplankas Išvalyti anksčiau išsaugotą grojimo būseną (jei yra) Daugiareikšmiai separatoriai @@ -275,4 +275,22 @@ Grojaraščiai Grojaraščio vaizdas %s Sukurti naują grojaraštį + Naujas grojaraštis + Įtraukti į grojaraštį + Įtraukta į grojaraštį + Ištrinti + Ištrinti %s\? To negalima atšaukti. + Pervadinti + Pervadinti grojaraštį + Ištrinti grojaraštį\? + Nėra dainų + Redaguoti + Bendrinti + Pervadintas grojaraštis + Grojaraštis %d + Sukurtas grojaraštis + Ištrintas grojaraštis + Nėra disko + Redaguojama %s + Rodoma \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c835f1de5..8016b72f2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -288,4 +288,16 @@ Brak utworów Dodano do playlisty Playlista %d + Usuwać + Usunąć %s\? Tego nie da się cofnąć. + Przemianować + Przemianować playlistę + Usunąć playlistę\? + Edytować + Pojawia się + Udział + Zmieniono nazwę playlisty + Playlista usunięta + Brak dysku + Edytowanie %s \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 449dc9662..26f8b19bc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -23,7 +23,7 @@ Сейчас играет Играть Перемешать - Играть все треки + Играть все композиции Играть альбом Играть исполнителя Очередь @@ -55,7 +55,7 @@ Вкладки Изменить видимость и порядок вкладок Скруглённые обложки - Включить скруглённые края на некоторых элементах интерфейса + Скруглить края на некоторых элементах интерфейса Кнопка в уведомлении Звук Воспроизводить при подключении @@ -63,7 +63,7 @@ Выравнивание громкости По треку По альбому - Предпочитать альбом, если он воспроизводится + Предпочитать по альбому, если он воспроизводится Поведение При воспроизведении из библиотеки Запоминать перемешивание @@ -76,7 +76,7 @@ Запоминать позицию Запоминать позицию в треке Обновить музыку - Обновлять библиотеку, при возможности используя кеш тегов + Обновлять библиотеку, при возможности используя кэш тегов Треков нет Ошибка чтения библиотеки @@ -106,13 +106,13 @@ Неизвестный исполнитель Неизвестный жанр - Дата отсутствует + Дата не указана Музыка не играет Красный Розовый Пурпурный - Темно-фиолетовый + Тёмно-фиолетовый Индиго Синий Тёмно-синий @@ -126,7 +126,7 @@ Коричневый Серый - Всего композиций: %d + Композиций загружено: %d %d трек %d трека @@ -152,16 +152,16 @@ Битрейт Диск Трек - Состояние восстановлено + Позиция восстановлена Отмена - Внимание: Изменение предусилителя на большое положительное значение может привести к появлению искажений на некоторых звуковых дорожках. + Внимание: Изменение предусиления на большое положительное значение может привести к появлению искажений на некоторых звуковых дорожках. Свойства Свойства песни Путь Формат Размер Частота дискретизации - Предусилитель применяется к существующей настройке во время воспроизведения + Предусиление применяется к существующей настройке во время воспроизведения Статистика библиотеки Восстановить состояние воспроизведения Продолжительность @@ -171,12 +171,12 @@ Сингл Дата добавления Синглы - Предусилитель ReplayGain + Предусиление ReplayGain Исключить AAC Очистить состояние воспроизведения Музыка не будет загружена из указанных папок. - Укажите, откуда загружать музыку + Укажите, откуда надо загружать музыку %d кбит/с Автоматическая перезагрузка MPEG-1 @@ -191,7 +191,7 @@ Концертный альбом Концертный Мониторинг изменений в музыкальной библиотеке… - Состояние очищено + Позиция очищена Папки с музыкой Включить Альбом ремиксов @@ -203,7 +203,7 @@ Сборники Очистить ранее сохраненное состояние воспроизведения (если есть) Перезагружать библиотеку при изменении (требует постоянное уведомление) - -%.1f дБ + −%.1f дБ Жанров загружено: %d Восстановить предыдущее состояние воспроизведения (если есть) Режим @@ -234,20 +234,20 @@ Точка с запятой (;) Плюс (+) Амперсанд (&) - Миксы + DJ Миксы Остановить воспроизведение - Игнорировать аудиофайлы, которые не являются музыкой, например, подкасты + Игнорировать аудиофайлы, которые не являются музыкой. Например, подкасты Обложки альбомов - Микс + DJ Микс Изменение по тегам Изменения без тегов Отключены Исходные (Быстрая загрузка) Повышенного качества (Медленная загрузка) - Подборка в реальном времени + Сборник концертных записей Пользовательское поведение панели воспроизведения Пересканировать музыку - Очистить кеш тегов и полностью обновить библиотеку (медленно, но более эффективно) + Очистить кэш тегов и полностью обновить библиотеку (медленно, но более точно) Сборник ремиксов %d исполнитель @@ -260,7 +260,7 @@ Предупреждение: Использование этой настройки может привести к тому, что некоторые теги будут неправильно интерпретироваться как имеющие несколько значений. Вы можете решить эту проблему, добавив к нежелательным символам-разделителям обратную косую черту (\\). Воспроизвести выбранное Перемешать выбранное - %d Выбрано + %d выбрано Вики Сбросить %1$s,%2$s @@ -271,7 +271,7 @@ Изображения Библиотека Настройка темы и цветов приложения - Настройка элементов управления и поведения пользовательского интерфейса + Настроить элементы управления и поведение интерфейса Управляйте загрузкой музыки и изображений Настройка звука и поведения при воспроизведении Воспроизведение @@ -290,4 +290,16 @@ Без треков Добавлено в плейлист Плейлист создан + Удалить + Переименовать + Переименовать плейлист + Удалить плейлист\? + Плейлист переименован + Плейлист удалён + Редактировать + Удалить %s\? Это действие не может быть отменено. + Появляется на + Редактирование %s + Нет диска + Поделиться \ No newline at end of file diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index bd7e5ac5e..314de547a 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -6,4 +6,23 @@ Једноставан, рационалан музички плејер за android. Музика се учитава Учитавање музике + Песме + Све песме + Албуми + Ремикси епизода + Синглес + Сингл + Ливе сингл + Жива компилација + Компилације + Компилација + Ремик компилација + Соундтрацкс + Соундтрацк + Албум + Ливе албум + Албум ремикса + Ливе епизод + Епизоде + Епизод \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e29bd03a1..038e65384 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -95,8 +95,8 @@ Мікстейпи Виконавець Тривалість - Мікси - Мікс + DJ мікси + DJ мікс Заокруглені обкладинки Завантажено жанрів: %d Властивості пісні @@ -204,7 +204,7 @@ Зупинити відтворення Вільний аудіокодек без втрат (FLAC) Темно-фіолетовий - Вибрано %d + Вибрано: %d Завантаження музичної бібліотеки… (%1$d/%2$d) %d кбіт/с %d Гц @@ -295,4 +295,8 @@ Перейменувати список відтворення Список відтворення перейменовано Редагувати + Поділитись + Редагування %s + Немає диску + З\'являється на \ No newline at end of file diff --git a/app/src/main/res/values-v23/styles_core.xml b/app/src/main/res/values-v23/styles_core.xml deleted file mode 100644 index c3bd17685..000000000 --- a/app/src/main/res/values-v23/styles_core.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 60ff2a42b..52243b67a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -231,8 +231,8 @@ 分号 (;) 重混集 实况音乐集 - 混音 - 混音 + DJ 混音 + DJ 混音 警告:使用此设置可能会导致某些标签被错误地阐释为具有多个值。要解决这个问题,你可以在不想要的分隔符前加上反斜杠 (\\)。 忽略不是音乐的音频文件,例如播客 排除非音乐 @@ -289,4 +289,8 @@ 已重命名播放列表 已删除播放列表 编辑 + 出现于 + 分享 + 无唱片 + 正在编辑 %s \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 4ca96d8e0..a320f9ea5 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -10,10 +10,15 @@ 200 100 - + - - + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 0600e089c..7dffd1917 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -20,13 +20,14 @@ 16dp 24dp + 48dp 56dp 64dp 72dp 24dp - 32dp + 32dp 56dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf01fa3dd..676133e13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,14 +61,16 @@ Mixtape - Mixes + DJ Mixes - Mix + DJ Mix Live Remixes + + Appears on Artist Artists @@ -120,6 +122,7 @@ Go to artist Go to album View properties + Share Song properties File name @@ -331,10 +334,10 @@ Unknown artist Unknown genre No date + No disc No track No songs No music playing - There\'s nothing here yet @@ -385,6 +388,8 @@ %d Selected + + Editing %s Disc %d diff --git a/app/src/main/res/values/styles_core.xml b/app/src/main/res/values/styles_core.xml index bbe18c90c..446c3064f 100644 --- a/app/src/main/res/values/styles_core.xml +++ b/app/src/main/res/values/styles_core.xml @@ -50,9 +50,15 @@ false true + + @color/sel_compat_ripple ?attr/colorOnSurfaceVariant ?attr/colorPrimary + + @color/overlay_text_highlight + @color/overlay_text_highlight_inverse + @style/PreferenceTheme.Auxio @style/Preference.Auxio @style/Preference.Auxio.PreferenceCategory diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 6e0ee0001..97611ce8d 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -34,42 +34,38 @@