Merge pull request #465 from OxygenCobalt/dev

Version 3.1.1
This commit is contained in:
Alexander Capehart 2023-06-04 02:05:46 +00:00 committed by GitHub
commit 6031fb2890
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
213 changed files with 3738 additions and 2006 deletions

View file

@ -23,8 +23,8 @@ jobs:
cache: gradle cache: gradle
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Test app with Gradle # - name: Test app with Gradle
run: ./gradlew app:testDebug # run: ./gradlew app:testDebug
- name: Build debug APK with Gradle - name: Build debug APK with Gradle
run: ./gradlew app:packageDebug run: ./gradlew app:packageDebug
- name: Upload debug APK artifact - name: Upload debug APK artifact

View file

@ -1,5 +1,32 @@
# Changelog # 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 ## 3.1.0
#### What's New #### What's New

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.0"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.1">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.0&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.1&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -11,7 +11,7 @@
<a href="https://www.gnu.org/licenses/gpl-3.0"> <a href="https://www.gnu.org/licenses/gpl-3.0">
<img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat"> <img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat">
</a> </a>
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-1450A8?style=flat"> <img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-24%2B-1450A8?style=flat">
</p> </p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4> <h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
<p align="center"> <p align="center">

View file

@ -20,10 +20,10 @@ android {
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.1.0" versionName "3.1.1"
versionCode 30 versionCode 31
minSdk 21 minSdk 24
targetSdk 33 targetSdk 33
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -86,13 +86,13 @@ dependencies {
// General // General
implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.core:core-ktx:1.10.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" implementation "androidx.fragment:fragment-ktx:1.5.7"
// UI // UI
implementation "androidx.recyclerview:recyclerview:1.3.0" implementation "androidx.recyclerview:recyclerview:1.3.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.4" 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' implementation 'androidx.core:core-ktx:1.10.1'
// Lifecycle // Lifecycle
@ -125,7 +125,7 @@ dependencies {
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
// Image loading // Image loading
implementation 'io.coil-kt:coil-base:2.3.0' implementation 'io.coil-kt:coil-base:2.4.0'
// Material // Material
// TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available

View file

@ -16,8 +16,6 @@
package com.google.android.material.bottomsheet; package com.google.android.material.bottomsheet;
import com.google.android.material.R;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static java.lang.Math.max; import static java.lang.Math.max;
import static java.lang.Math.min; import static java.lang.Math.min;
@ -44,6 +42,7 @@ import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams; import android.view.ViewGroup.MarginLayoutParams;
import android.view.ViewParent; import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.FloatRange; import androidx.annotation.FloatRange;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -63,11 +62,14 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.Accessibilit
import androidx.core.view.accessibility.AccessibilityViewCommand; import androidx.core.view.accessibility.AccessibilityViewCommand;
import androidx.customview.view.AbsSavedState; import androidx.customview.view.AbsSavedState;
import androidx.customview.widget.ViewDragHelper; 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;
import com.google.android.material.internal.ViewUtils.RelativePadding; import com.google.android.material.internal.ViewUtils.RelativePadding;
import com.google.android.material.resources.MaterialResources; import com.google.android.material.resources.MaterialResources;
import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.MaterialShapeDrawable;
import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.shape.ShapeAppearanceModel;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
@ -1334,6 +1336,19 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return state; 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) { void setStateInternal(@State int state) {
if (this.state == state) { if (this.state == state) {
return; return;

View file

@ -16,20 +16,16 @@
package com.google.android.material.divider; package com.google.android.material.divider;
import com.google.android.material.R;
import android.content.Context; import android.content.Context;
import android.content.res.TypedArray; import android.content.res.TypedArray;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable; 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.util.AttributeSet;
import android.view.View; import android.view.View;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import androidx.annotation.ColorInt; import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes; import androidx.annotation.ColorRes;
import androidx.annotation.DimenRes; import androidx.annotation.DimenRes;
@ -39,6 +35,11 @@ import androidx.annotation.Px;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.view.ViewCompat; 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.internal.ThemeEnforcement;
import com.google.android.material.resources.MaterialResources; import com.google.android.material.resources.MaterialResources;

View file

@ -36,6 +36,7 @@ import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
@ -50,8 +51,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* TODO: Unit testing * TODO: Unit testing
* TODO: Fix UID naming * TODO: Fix UID naming
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
* TODO: Add more logging * TODO: Improve multi-threading support in shared objects
* TODO: Try to move on from synchronized and volatile in shared objs
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -121,6 +121,7 @@ class MainActivity : AppCompatActivity() {
private fun startIntentAction(intent: Intent?): Boolean { private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) { if (intent == null) {
// Nothing to do. // Nothing to do.
logD("No intent to handle")
return false return false
} }
@ -129,6 +130,7 @@ class MainActivity : AppCompatActivity() {
// This is because onStart can run multiple times, and thus we really don't // 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 // want to return false and override the original delayed action with a
// RestoreState action. // RestoreState action.
logD("Already used this intent")
return true return true
} }
intent.putExtra(KEY_INTENT_USED, true) intent.putExtra(KEY_INTENT_USED, true)
@ -137,8 +139,12 @@ class MainActivity : AppCompatActivity() {
when (intent.action) { when (intent.action) {
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false) Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll 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) playbackModel.startAction(action)
return true return true
} }

View file

@ -26,11 +26,13 @@ import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.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.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.transition.MaterialFadeThrough 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.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
import org.oxycblt.auxio.ui.ViewBindingFragment 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 * A wrapper around the home fragment that shows the playback fragment and controls the more
* high-level navigation features. * high-level navigation features.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Break up the god navigation setup going on here
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainFragment : class MainFragment :
@ -68,7 +81,10 @@ class MainFragment :
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels()
private val detailModel: DetailViewModel 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 lastInsets: WindowInsets? = null
private var elevationNormal = 0f private var elevationNormal = 0f
private var initialNavDestinationChange = true private var initialNavDestinationChange = true
@ -84,13 +100,38 @@ class MainFragment :
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) 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) 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 --- // --- UI SETUP ---
val context = requireActivity() val context = requireActivity()
// Override the back pressed listener so we can map back navigation to collapsing // Override the back pressed listener so we can map back navigation to collapsing
// navigation, navigation out of detail views, etc. // navigation, navigation out of detail views, etc.
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) context.onBackPressedDispatcher.apply {
addCallback(viewLifecycleOwner, exploreBackCallback)
addCallback(viewLifecycleOwner, selectionBackCallback)
addCallback(viewLifecycleOwner, detailBackCallback)
addCallback(viewLifecycleOwner, sheetBackCallback)
}
binding.root.setOnApplyWindowInsetsListener { _, insets -> binding.root.setOnApplyWindowInsetsListener { _, insets ->
lastInsets = insets lastInsets = insets
@ -103,13 +144,10 @@ class MainFragment :
ViewCompat.setAccessibilityPaneTitle( ViewCompat.setAccessibilityPaneTitle(
binding.queueSheet, context.getString(R.string.lbl_queue)) binding.queueSheet, context.getString(R.string.lbl_queue))
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
if (queueSheetBehavior != null) { if (queueSheetBehavior != null) {
// Bottom sheet mode, set up click listeners. // In portrait mode, set up click listeners on the stacked sheets.
val playbackSheetBehavior = logD("Configuring stacked bottom sheets")
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is expanded and queue sheet is collapsed, we can expand it. // Playback sheet is expanded and queue sheet is collapsed, we can expand it.
@ -118,14 +156,15 @@ class MainFragment :
} }
} else { } else {
// Dual-pane mode, manually style the static queue sheet. // Dual-pane mode, manually style the static queue sheet.
logD("Configuring dual-pane bottom sheet")
binding.queueSheet.apply { binding.queueSheet.apply {
// Emulate the elevated bottom sheet style. // Emulate the elevated bottom sheet style.
background = background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply { MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface) fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal) 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 -> setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(top = insets.systemBarInsetsCompat.top) v.updatePadding(top = insets.systemBarInsetsCompat.top)
insets insets
@ -134,13 +173,15 @@ class MainFragment :
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist) collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -165,6 +206,14 @@ class MainFragment :
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this) 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 { override fun onPreDraw(): Boolean {
// We overload CoordinatorLayout far too much to rely on any of it's typical // We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should // 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 // Since the navigation listener is also reliant on the bottom sheets, we must also update
// it every frame. // it every frame.
callback.invalidateEnabled() requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled()
return true return true
} }
@ -263,6 +313,8 @@ class MainFragment :
// Drop the initial call by NavController that simply provides us with the current // 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 // destination. This would cause the selection state to be lost every time the device
// rotates. // rotates.
requireNotNull(exploreBackCallback) { "ExploreBackPressedCallback was not available" }
.invalidateEnabled()
if (!initialNavDestinationChange) { if (!initialNavDestinationChange) {
initialNavDestinationChange = true initialNavDestinationChange = true
return return
@ -271,19 +323,15 @@ class MainFragment :
} }
private fun handleMainNavigation(action: MainNavigationAction?) { private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) { if (action != null) {
// Nothing to do. when (action) {
return 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?) { private fun handleExploreNavigation(item: Music?) {
@ -368,6 +416,7 @@ class MainFragment :
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it. // Playback sheet is not expanded and not hidden, we can expand it.
logD("Expanding playback sheet")
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
return return
} }
@ -378,6 +427,7 @@ class MainFragment :
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Queue sheet and playback sheet is expanded, close the queue sheet so the // Queue sheet and playback sheet is expanded, close the queue sheet so the
// playback panel can eb shown. // playback panel can eb shown.
logD("Collapsing queue sheet")
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
} }
} }
@ -388,6 +438,7 @@ class MainFragment :
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Playback sheet (and possibly queue) needs to be collapsed. // Playback sheet (and possibly queue) needs to be collapsed.
logD("Collapsing playback and queue sheets")
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -399,7 +450,8 @@ class MainFragment :
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior 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 = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed // Queue sheet behavior is either collapsed or expanded, no hiding needed
@ -416,10 +468,12 @@ class MainFragment :
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { if (playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? 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. // Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply { queueSheetBehavior?.apply {
isDraggable = false isDraggable = false
@ -433,71 +487,86 @@ class MainFragment :
} }
} }
/** // TODO: Use targetState more
* 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?
private class SheetBackPressedCallback(
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
) : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
// If expanded, collapse the queue sheet first. // If expanded, collapse the queue sheet first.
if (queueSheetBehavior != null && if (queueSheetShown()) {
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && unlikelyToBeNull(queueSheetBehavior).state =
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { BackportBottomSheetBehavior.STATE_COLLAPSED
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED logD("Collapsed queue sheet")
return return
} }
// If expanded, collapse the playback sheet next. // If expanded, collapse the playback sheet next.
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && if (playbackSheetShown()) {
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed playback sheet")
return 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() { fun invalidateEnabled() {
val binding = requireBinding() isEnabled = queueSheetShown() || playbackSheetShown()
val playbackSheetBehavior = }
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val exploreNavController = binding.exploreNavHost.findNavController()
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<Song>?) {
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<Music>) {
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 = isEnabled =
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED || exploreNavController.currentDestination?.id !=
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED || exploreNavController.graph.startDestinationId
detailModel.editedPlaylist.value != null ||
selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId
} }
} }
} }

View file

@ -51,7 +51,16 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel 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]. * A [ListFragment] that shows information about an [Album].
@ -156,7 +165,14 @@ class AlbumDetailFragment :
musicModel.addToPlaylist(currentAlbum) musicModel.addToPlaylist(currentAlbum)
true 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?) { private fun updateAlbum(album: Album?) {
if (album == null) { if (album == null) {
// Album we were showing no longer exists. logD("No album to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
@ -219,12 +235,8 @@ class AlbumDetailFragment :
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { albumListAdapter.setPlaying(
albumListAdapter.setPlaying(song, isPlaying) song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
albumListAdapter.setPlaying(null, isPlaying)
}
} }
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {
@ -291,7 +303,7 @@ class AlbumDetailFragment :
boxStart: Int, boxStart: Int,
boxEnd: Int, boxEnd: Int,
snapPreference: Int snapPreference: Int
): Int = ) =
(boxStart + (boxEnd - boxStart) / 2) - (boxStart + (boxEnd - boxStart) / 2) -
(viewStart + (viewEnd - viewStart) / 2) (viewStart + (viewEnd - viewStart) / 2)
} }

View file

@ -49,7 +49,15 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel 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]. * A [ListFragment] that shows information about an [Artist].
@ -153,7 +161,14 @@ class ArtistDetailFragment :
musicModel.addToPlaylist(currentArtist) musicModel.addToPlaylist(currentArtist)
true 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?) { private fun updateArtist(artist: Artist?) {
if (artist == null) { if (artist == null) {
// Artist we were showing no longer exists. logD("No artist to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return 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) artistHeaderAdapter.setParent(artist)
} }
@ -234,14 +261,14 @@ class ArtistDetailFragment :
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
val playingItem = val playingItem =
when (parent) { when (parent) {
// Always highlight a playing album if it's from this artist. // Always highlight a playing album if it's from this artist, and if the currently
is Album -> parent // playing song is contained within.
is Album -> parent.takeIf { song?.album == it }
// If the parent is the artist itself, use the currently playing song. // If the parent is the artist itself, use the currently playing song.
currentArtist -> song currentArtist -> song
// Nothing is playing from this artist. // Nothing is playing from this artist.
else -> null else -> null
} }
artistListAdapter.setPlaying(playingItem, isPlaying) artistListAdapter.setPlaying(playingItem, isPlaying)
} }

View file

@ -35,6 +35,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField 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 * 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 { (TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
// We can never properly initialize the title view's state before draw time, // 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 // so we just set it's alpha to 0f to produce a less jarring initialization
// animation.. // animation.
alpha = 0f alpha = 0f
} }
@ -101,12 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (titleShown == visible) return if (titleShown == visible) return
titleShown = visible 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 // Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
// the title view's alpha instead of the AppBarLayout's elevation. // the title view's alpha instead of the AppBarLayout's elevation.
val titleView = findTitleView() val titleView = findTitleView()
@ -126,7 +121,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return return
} }
this.titleAnimator = logD("Changing title visibility [from: $from to: $to]")
titleAnimator?.cancel()
titleAnimator =
ValueAnimator.ofFloat(from, to).apply { ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView.alpha = it.animatedValue as Float } addUpdateListener { titleView.alpha = it.animatedValue as Float }
duration = duration =

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.R 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.EditHeader
import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader 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.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.info.Disc 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.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaybackSettings 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 * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
@ -60,7 +71,7 @@ constructor(
private val playbackSettings: PlaybackSettings private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.UpdateListener { ) : ViewModel(), MusicRepository.UpdateListener {
// --- SONG --- // --- SONG ---
private var currentSongJob: Job? = null private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null) private val _currentSong = MutableStateFlow<Song?>(null)
@ -219,9 +230,9 @@ constructor(
if (changes.userLibrary && userLibrary != null) { if (changes.userLibrary && userLibrary != null) {
val playlist = currentPlaylist.value val playlist = currentPlaylist.value
if (playlist != null) { if (playlist != null) {
logD("Updated playlist to ${currentPlaylist.value}")
_currentPlaylist.value = _currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) 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. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSong(uid: Music.UID) { fun setSong(uid: Music.UID) {
logD("Opening Song [uid: $uid]") logD("Opening song $uid")
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo) _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. * @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/ */
fun setAlbum(uid: Music.UID) { fun setAlbum(uid: Music.UID) {
logD("Opening Album [uid: $uid]") logD("Opening album $uid")
_currentAlbum.value = _currentAlbum.value =
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) 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. * @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/ */
fun setArtist(uid: Music.UID) { fun setArtist(uid: Music.UID) {
logD("Opening Artist [uid: $uid]") logD("Opening artist $uid")
_currentArtist.value = _currentArtist.value =
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) 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. * @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/ */
fun setGenre(uid: Music.UID) { fun setGenre(uid: Music.UID) {
logD("Opening Genre [uid: $uid]") logD("Opening genre $uid")
_currentGenre.value = _currentGenre.value =
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) 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. * @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
*/ */
fun setPlaylist(uid: Music.UID) { fun setPlaylist(uid: Music.UID) {
logD("Opening Playlist [uid: $uid]") logD("Opening playlist $uid")
_currentPlaylist.value = _currentPlaylist.value =
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) 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. */ /** Start a playlist editing session. Does nothing if a playlist is not being shown. */
@ -300,6 +326,7 @@ constructor(
fun savePlaylistEdit() { fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = _editedPlaylist.value ?: return val editedPlaylist = _editedPlaylist.value ?: return
logD("Committing playlist edits")
viewModelScope.launch { viewModelScope.launch {
musicRepository.rewritePlaylist(playlist, editedPlaylist) musicRepository.rewritePlaylist(playlist, editedPlaylist)
// TODO: The user could probably press some kind of button if they were fast enough. // TODO: The user could probably press some kind of button if they were fast enough.
@ -320,6 +347,7 @@ constructor(
// Nothing to do. // Nothing to do.
return false return false
} }
logD("Discarding playlist edits")
_editedPlaylist.value = null _editedPlaylist.value = null
refreshPlaylistList(playlist) refreshPlaylistList(playlist)
return true return true
@ -341,6 +369,7 @@ constructor(
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) { if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false return false
} }
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
@ -359,6 +388,7 @@ constructor(
if (realAt !in editedPlaylist.indices) { if (realAt !in editedPlaylist.indices) {
return return
} }
logD("Removing playlist song at $realAt [$at]")
editedPlaylist.removeAt(realAt) editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylistList( refreshPlaylistList(
@ -366,11 +396,13 @@ constructor(
if (editedPlaylist.isNotEmpty()) { if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1) UpdateInstructions.Remove(at, 1)
} else { } else {
logD("Playlist will be empty after removal, removing header")
UpdateInstructions.Remove(at - 2, 3) UpdateInstructions.Remove(at - 2, 3)
}) })
} }
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
logD("Refreshing audio info")
// Clear any previous job in order to avoid stale data from appearing in the UI. // Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel() currentSongJob?.cancel()
_songAudioProperties.value = null _songAudioProperties.value = null
@ -378,6 +410,7 @@ constructor(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val info = audioPropertiesFactory.extract(song) val info = audioPropertiesFactory.extract(song)
yield() yield()
logD("Updating audio info to $info")
_songAudioProperties.value = info _songAudioProperties.value = info
} }
} }
@ -399,12 +432,11 @@ constructor(
// To create a good user experience regarding disc numbers, we group the album's // 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. // songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs) val songs = albumSongSort.songs(album.songs)
// Songs without disc tags become part of Disc 1. val byDisc = songs.groupBy { it.disc }
val byDisc = songs.groupBy { it.disc ?: Disc(1, null) }
if (byDisc.size > 1) { if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers") logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) { for (entry in byDisc.entries) {
list.add(entry.key) list.add(DiscHeader(entry.key))
list.addAll(entry.value) list.addAll(entry.value)
} }
} else { } else {
@ -412,6 +444,7 @@ constructor(
list.addAll(songs) list.addAll(songs)
} }
logD("Update album list to ${list.size} items with $instructions")
_albumInstructions.put(instructions) _albumInstructions.put(instructions)
_albumList.value = list _albumList.value = list
} }
@ -419,10 +452,9 @@ constructor(
private fun refreshArtistList(artist: Artist, replace: Boolean = false) { private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist list") logD("Refreshing artist list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup = val grouping =
albums.groupBy { artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into an easier // Remap the complicated ReleaseType data structure into an easier
// "AlbumGrouping" enum that will automatically group and sort // "AlbumGrouping" enum that will automatically group and sort
// the artist's albums. // the artist's albums.
@ -436,15 +468,25 @@ constructor(
is ReleaseType.Single -> AlbumGrouping.SINGLES is ReleaseType.Single -> AlbumGrouping.SINGLES
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is ReleaseType.Mix -> AlbumGrouping.MIXES is ReleaseType.Mix -> AlbumGrouping.DJMIXES
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES 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, List<Album>>)[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) val header = BasicHeader(entry.key.headerTitleRes)
list.add(Divider(header)) list.add(Divider(header))
list.add(header) list.add(header)
@ -465,6 +507,7 @@ constructor(
list.addAll(artistSongSort.songs(artist.songs)) list.addAll(artistSongSort.songs(artist.songs))
} }
logD("Updating artist list to ${list.size} items with $instructions")
_artistInstructions.put(instructions) _artistInstructions.put(instructions)
_artistList.value = list.toList() _artistList.value = list.toList()
} }
@ -483,12 +526,14 @@ constructor(
list.add(songHeader) list.add(songHeader)
val instructions = val instructions =
if (replace) { 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) UpdateInstructions.Replace(list.size)
} else { } else {
UpdateInstructions.Diff UpdateInstructions.Diff
} }
list.addAll(genreSongSort.songs(genre.songs)) list.addAll(genreSongSort.songs(genre.songs))
logD("Updating genre list to ${list.size} items with $instructions")
_genreInstructions.put(instructions) _genreInstructions.put(instructions)
_genreList.value = list _genreList.value = list
} }
@ -508,6 +553,7 @@ constructor(
list.addAll(songs) list.addAll(songs)
} }
logD("Updating playlist list to ${list.size} items with $instructions")
_playlistInstructions.put(instructions) _playlistInstructions.put(instructions)
_playlistList.value = list _playlistList.value = list
} }
@ -524,8 +570,9 @@ constructor(
SINGLES(R.string.lbl_singles), SINGLES(R.string.lbl_singles),
COMPILATIONS(R.string.lbl_compilations), COMPILATIONS(R.string.lbl_compilations),
SOUNDTRACKS(R.string.lbl_soundtracks), SOUNDTRACKS(R.string.lbl_soundtracks),
MIXES(R.string.lbl_mixes), DJMIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes), MIXTAPES(R.string.lbl_mixtapes),
APPEARANCES(R.string.lbl_appears_on),
LIVE(R.string.lbl_live_group), LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group), REMIXES(R.string.lbl_remix_group),
} }

View file

@ -41,10 +41,24 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionViewModel 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.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel 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]. * A [ListFragment] that shows information for a particular [Genre].
@ -146,7 +160,14 @@ class GenreDetailFragment :
musicModel.addToPlaylist(currentGenre) musicModel.addToPlaylist(currentGenre)
true 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?) { private fun updatePlaylist(genre: Genre?) {
if (genre == null) { if (genre == null) {
// Genre we were showing no longer exists. logD("No genre to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
@ -222,15 +243,18 @@ class GenreDetailFragment :
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
var playingMusic: Music? = null val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
if (parent is Artist) { val playingItem =
playingMusic = parent when (parent) {
} // Always highlight a playing artist if it's from this genre, and if the currently
// Prefer songs that might be playing from this genre. // playing song is contained within.
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) { is Artist -> parent.takeIf { song?.run { artists.contains(it) } ?: false }
playingMusic = song // If the parent is the artist itself, use the currently playing song.
} currentGenre -> song
genreListAdapter.setPlaying(playingMusic, isPlaying) // Nothing is playing from this artist.
else -> null
}
genreListAdapter.setPlaying(playingItem, isPlaying)
} }
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {

View file

@ -44,10 +44,24 @@ import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel 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.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel 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]. * A [ListFragment] that shows information for a particular [Playlist].
@ -197,11 +211,18 @@ class PlaylistDetailFragment :
musicModel.deletePlaylist(currentPlaylist) musicModel.deletePlaylist(currentPlaylist)
true true
} }
R.id.action_share -> {
requireContext().share(currentPlaylist)
true
}
R.id.action_save -> { R.id.action_save -> {
detailModel.savePlaylistEdit() detailModel.savePlaylistEdit()
true true
} }
else -> false else -> {
logW("Unexpected menu item selected")
false
}
} }
} }
@ -238,19 +259,26 @@ class PlaylistDetailFragment :
return return
} }
val binding = requireBinding() val binding = requireBinding()
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext()) binding.detailNormalToolbar.apply {
binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}" 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) playlistHeaderAdapter.setParent(playlist)
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// Prefer songs that might be playing from this playlist. // Prefer songs that are playing from this playlist.
if (parent is Playlist && playlistListAdapter.setPlaying(
parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) { song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying)
playlistListAdapter.setPlaying(song, isPlaying)
} else {
playlistListAdapter.setPlaying(null, isPlaying)
}
} }
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {
@ -287,6 +315,7 @@ class PlaylistDetailFragment :
selectionModel.drop() selectionModel.drop()
if (editedPlaylist != null) { if (editedPlaylist != null) {
logD("Updating save button state")
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply { requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
} }
@ -308,9 +337,18 @@ class PlaylistDetailFragment :
private fun updateMultiToolbar() { private fun updateMultiToolbar() {
val id = val id =
when { when {
detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar detailModel.editedPlaylist.value != null -> {
selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar logD("Currently editing playlist, showing edit toolbar")
else -> R.id.detail_normal_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) requireBinding().detailToolbar.setVisible(id)

View file

@ -22,8 +22,8 @@ import android.content.Context
import android.os.Build import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.appcompat.R
import com.google.android.material.textfield.TextInputEditText 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 * A [TextInputEditText] that deliberately restricts all input except for selection. This will work

View file

@ -41,6 +41,7 @@ import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.logD
/** /**
* A [ViewBindingDialogFragment] that shows information about a Song. * A [ViewBindingDialogFragment] that shows information about a Song.
@ -73,7 +74,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
private fun updateSong(song: Song?, info: AudioProperties?) { private fun updateSong(song: Song?, info: AudioProperties?) {
if (song == null) { if (song == null) {
// Song we were showing no longer exists. logD("No song to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
@ -86,7 +87,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
add(SongProperty(R.string.lbl_album, song.album.zipName(context))) 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_artists, song.artists.zipNames(context)))
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(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 { song.track?.let {
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it))) add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
} }

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/** /**
* A [DetailHeaderAdapter] that shows [Artist] information. * 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. // The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information. // ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those. // 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.detailSubhead.isVisible = false
binding.detailPlayButton.isEnabled = false binding.detailPlayButton.isEnabled = false
binding.detailShuffleButton.isEnabled = false binding.detailShuffleButton.isEnabled = false

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.detail.header
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.util.logD
/** /**
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view. * A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
@ -47,6 +48,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
* @param parent The new [MusicParent] to show. * @param parent The new [MusicParent] to show.
*/ */
fun setParent(parent: T) { fun setParent(parent: T) {
logD("Updating parent [old: $currentParent new: $parent]")
currentParent = parent currentParent = parent
rebindParent() rebindParent()
} }
@ -55,6 +57,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation. * Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/ */
protected fun rebindParent() { protected fun rebindParent() {
logD("Rebinding parent")
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER) notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
} }

View file

@ -30,6 +30,7 @@ import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/** /**
* A [DetailHeaderAdapter] that shows [Playlist] information. * A [DetailHeaderAdapter] that shows [Playlist] information.
@ -57,6 +58,7 @@ class PlaylistDetailHeaderAdapter(private val listener: Listener) :
// Nothing to do. // Nothing to do.
return return
} }
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
editedPlaylist = songs editedPlaylist = songs
rebindParent() rebindParent()
} }
@ -83,8 +85,16 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
editedPlaylist: List<Song>?, editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener listener: DetailHeaderAdapter.Listener
) { ) {
// TODO: Debug perpetually re-binding images if (editedPlaylist != null) {
binding.detailCover.bind(playlist, editedPlaylist) 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.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context) binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text. // 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) 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 { binding.detailPlayButton.apply {
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null isEnabled = playable
setOnClickListener { listener.onPlay() } setOnClickListener { listener.onPlay() }
} }
binding.detailShuffleButton.apply { binding.detailShuffleButton.apply {
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null isEnabled = playable
setOnClickListener { listener.onShuffle() } setOnClickListener { listener.onShuffle() }
} }
} }

View file

@ -22,6 +22,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R 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.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater 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. * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@ -49,14 +51,14 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Support sub-headers for each disc, and special album songs. // 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 is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent) DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
@ -64,7 +66,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
when (val item = getItem(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) is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
} }
} }
@ -76,7 +78,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
override fun areContentsTheSame(oldItem: Item, newItem: Item) = override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when { when {
oldItem is Disc && newItem is Disc -> oldItem is Disc && newItem is Disc ->
DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -88,23 +90,37 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
} }
/** /**
* A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from] * A wrapper around [Disc] signifying that a header should be shown for a disc group.
* to create an instance.
* *
* @author Alexander Capehart (OxygenCobalt) * @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) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
* *
* @param disc The new [disc] to bind. * @param discHeader The new [DiscHeader] to bind.
*/ */
fun bind(disc: Disc) { fun bind(discHeader: DiscHeader) {
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) val disc = discHeader.inner
binding.discName.apply { if (disc != null) {
text = disc.name binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
isGone = disc.name == null 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. * @return A new instance.
*/ */
fun from(parent: View) = fun from(parent: View) =
DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
@ -147,31 +163,33 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
fun bind(song: Song, listener: SelectableListListener<Song>) { fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songTrack.apply { val track = song.track
if (song.track != null) { if (track != null) {
// Instead of an album cover, we show the track number, as the song list binding.songTrackCover.contentDescription =
// within the album detail view would have homogeneous album covers otherwise. binding.context.getString(R.string.desc_track_number, track)
binding.songTrackText.apply {
isVisible = true
text = context.getString(R.string.fmt_number, song.track) 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) binding.songName.text = song.name.resolve(binding.context)
// Use duration instead of album or artist for each song to be more contextually relevant.
// Use duration instead of album or artist for each song, as this text would
// be homogenous otherwise.
binding.songDuration.text = song.durationMs.formatDurationMs(false) binding.songDuration.text = song.durationMs.formatDurationMs(false)
} }
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.songTrackBg.isPlaying = isPlaying binding.songTrackCover.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {

View file

@ -29,7 +29,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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.context
import org.oxycblt.auxio.util.inflater 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying binding.parentImage.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying binding.songAlbumCover.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {

View file

@ -31,8 +31,10 @@ import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.* import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.* 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.music.Music
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater

View file

@ -27,6 +27,7 @@ import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R 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.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater 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] * 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. // Nothing to do.
return return
} }
logD("Updating editing state [old: $isEditing new: $editing]")
this.isEditing = editing this.isEditing = editing
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED) 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 delete = binding.background
override val background = override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { 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) elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0 alpha = 0
} }
@ -223,7 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
LayerDrawable( LayerDrawable(
arrayOf( arrayOf(
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply { MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
}, },
background)) background))
} }
@ -253,7 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.interactBody.isSelected = isActive binding.interactBody.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying binding.songAlbumCover.setPlaying(isPlaying)
} }
override fun updateEditing(editing: Boolean) { override fun updateEditing(editing: Boolean) {

View file

@ -24,7 +24,8 @@ import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item 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.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater

View file

@ -22,8 +22,8 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.google.android.material.R
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
@ -44,23 +44,32 @@ constructor(
override fun show() { override fun show() {
// Will already show eventually, need to do nothing. // 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 // Apply the new configuration possibly set in flipTo. This should occur even if
// a flip was canceled by a hide. // a flip was canceled by a hide.
pendingConfig?.run { pendingConfig?.run {
this@FlipFloatingActionButton.logD("Applying pending configuration")
setImageResource(iconRes) setImageResource(iconRes)
contentDescription = context.getString(contentDescriptionRes) contentDescription = context.getString(contentDescriptionRes)
setOnClickListener(clickListener) setOnClickListener(clickListener)
} }
pendingConfig = null pendingConfig = null
logD("Beginning show")
super.show() super.show()
} }
override fun hide() { 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. // Not flipping anymore, disable the flag so that the FAB is not re-shown.
flipping = false flipping = false
// Don't pass any kind of listener so that future flip operations will not be able // Don't pass any kind of listener so that future flip operations will not be able
// to show the FAB again. // to show the FAB again.
logD("Beginning hide")
super.hide() super.hide()
} }
@ -82,9 +91,12 @@ constructor(
// Already hiding for whatever reason, apply the configuration when the FAB is shown again. // Already hiding for whatever reason, apply the configuration when the FAB is shown again.
if (!isOrWillBeHidden) { if (!isOrWillBeHidden) {
logD("Starting hide for flip")
flipping = true flipping = true
// We will re-show the FAB later, assuming that there was not a prior flip operation. // We will re-show the FAB later, assuming that there was not a prior flip operation.
super.hide(FlipVisibilityListener()) super.hide(FlipVisibilityListener())
} else {
logD("Already hiding, will apply config later")
} }
} }
@ -97,7 +109,7 @@ constructor(
private inner class FlipVisibilityListener : OnVisibilityChangedListener() { private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) { override fun onHidden(fab: FloatingActionButton) {
if (!flipping) return if (!flipping) return
logD("Showing for a flip operation") logD("Starting show for flip")
flipping = false flipping = false
show() show()
} }

View file

@ -46,16 +46,39 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding 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.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel 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.MainNavigationAction
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel 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 * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
@ -188,54 +211,65 @@ class HomeFragment :
return true return true
} }
when (item.itemId) { return when (item.itemId) {
// Handle main actions (Search, Settings, About) // Handle main actions (Search, Settings, About)
R.id.action_search -> { R.id.action_search -> {
logD("Navigating to search") logD("Navigating to search")
setupAxisTransitions(MaterialSharedAxis.Z) setupAxisTransitions(MaterialSharedAxis.Z)
findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch()) findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch())
true
} }
R.id.action_settings -> { R.id.action_settings -> {
logD("Navigating to settings") logD("Navigating to settings")
navModel.mainNavigateTo( navModel.mainNavigateTo(
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings())) MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
true
} }
R.id.action_about -> { R.id.action_about -> {
logD("Navigating to about") logD("Navigating to about")
navModel.mainNavigateTo( navModel.mainNavigateTo(
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout())) MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
true
} }
// Handle sort menu // Handle sort menu
R.id.submenu_sorting -> { R.id.submenu_sorting -> {
// Junk click event when opening the menu // Junk click event when opening the menu
true
} }
R.id.option_sort_asc -> { R.id.option_sort_asc -> {
logD("Switching to ascending sorting")
item.isChecked = true item.isChecked = true
homeModel.setSortForCurrentTab( homeModel.setSortForCurrentTab(
homeModel homeModel
.getSortForTab(homeModel.currentTabMode.value) .getSortForTab(homeModel.currentTabMode.value)
.withDirection(Sort.Direction.ASCENDING)) .withDirection(Sort.Direction.ASCENDING))
true
} }
R.id.option_sort_dec -> { R.id.option_sort_dec -> {
logD("Switching to descending sorting")
item.isChecked = true item.isChecked = true
homeModel.setSortForCurrentTab( homeModel.setSortForCurrentTab(
homeModel homeModel
.getSortForTab(homeModel.currentTabMode.value) .getSortForTab(homeModel.currentTabMode.value)
.withDirection(Sort.Direction.DESCENDING)) .withDirection(Sort.Direction.DESCENDING))
true
} }
else -> { else -> {
// Sorting option was selected, mark it as selected and update the mode val newMode = Sort.Mode.fromItemId(item.itemId)
item.isChecked = true if (newMode != null) {
homeModel.setSortForCurrentTab( // Sorting option was selected, mark it as selected and update the mode
homeModel logD("Updating sort mode")
.getSortForTab(homeModel.currentTabMode.value) item.isChecked = true
.withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId)))) 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) { private fun setupPager(binding: FragmentHomeBinding) {
@ -246,6 +280,7 @@ class HomeFragment :
if (homeModel.currentTabModes.size == 1) { if (homeModel.currentTabModes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing // A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior. // behavior.
logD("Single tab shown, disabling TabLayout")
binding.homeTabs.isVisible = false binding.homeTabs.isVisible = false
binding.homeAppbar.setExpanded(true, false) binding.homeAppbar.setExpanded(true, false)
toolbarParams.scrollFlags = 0 toolbarParams.scrollFlags = 0
@ -270,17 +305,26 @@ class HomeFragment :
val isVisible: (Int) -> Boolean = val isVisible: (Int) -> Boolean =
when (tabMode) { when (tabMode) {
// Disallow sorting by count for songs // 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 // 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 // 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_asc ||
id == R.id.option_sort_dec || id == R.id.option_sort_dec ||
id == R.id.option_sort_name || id == R.id.option_sort_name ||
id == R.id.option_sort_count || id == R.id.option_sort_count ||
id == R.id.option_sort_duration id == R.id.option_sort_duration
} })
}
} }
val sortMenu = val sortMenu =
@ -288,18 +332,29 @@ class HomeFragment :
val toHighlight = homeModel.getSortForTab(tabMode) val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) { 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. // the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId || if (isCurrentMode || isCurrentlyAscending || isCurrentlyDescending) {
(option.itemId == R.id.option_sort_asc && logD(
toHighlight.direction == Sort.Direction.ASCENDING) || "Checking $option option [mode: $isCurrentMode asc: $isCurrentlyAscending dec: $isCurrentlyDescending]")
(option.itemId == R.id.option_sort_dec && // Note: We cannot inline this boolean assignment since it unchecks all other radio
toHighlight.direction == Sort.Direction.DESCENDING)) { // buttons (even when setting it to false), which would result in nothing being
// selected.
option.isChecked = true option.isChecked = true
} }
// Disable options that are not allowed by the isVisible lambda // Disable options that are not allowed by the isVisible lambda
option.isVisible = isVisible(option.itemId) 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 // Update the scrolling view in AppBarLayout to align with the current tab's
@ -315,10 +370,12 @@ class HomeFragment :
} }
if (tabMode != MusicMode.PLAYLISTS) { if (tabMode != MusicMode.PLAYLISTS) {
logD("Flipping to shuffle button")
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) { binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
playbackModel.shuffleAll() playbackModel.shuffleAll()
} }
} else { } else {
logD("Flipping to playlist button")
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) { binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
musicModel.createPlaylist() musicModel.createPlaylist()
} }
@ -328,6 +385,7 @@ class HomeFragment :
private fun handleRecreate(recreate: Unit?) { private fun handleRecreate(recreate: Unit?) {
if (recreate == null) return if (recreate == null) return
val binding = requireBinding() val binding = requireBinding()
logD("Recreating ViewPager")
// Move back to position zero, as there must be a tab there. // Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0 binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration. // Make sure tabs are set up to also follow the new ViewPager configuration.
@ -364,7 +422,7 @@ class HomeFragment :
binding.homeIndexingProgress.visibility = View.INVISIBLE binding.homeIndexingProgress.visibility = View.INVISIBLE
when (error) { when (error) {
is NoAudioPermissionException -> { is NoAudioPermissionException -> {
logD("Updating UI to permission request state") logD("Showing permission prompt")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher. // Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
@ -379,7 +437,7 @@ class HomeFragment :
} }
} }
is NoMusicException -> { is NoMusicException -> {
logD("Updating UI to no music state") logD("Showing no music error")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger. // Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
@ -389,7 +447,7 @@ class HomeFragment :
} }
} }
else -> { else -> {
logD("Updating UI to error state") logD("Showing generic error")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger. // Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
@ -432,8 +490,10 @@ class HomeFragment :
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll // 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. // popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (songs.isEmpty() || isFastScrolling) { if (songs.isEmpty() || isFastScrolling) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
binding.homeFab.hide() binding.homeFab.hide()
} else { } else {
logD("Showing fab")
binding.homeFab.show() binding.homeFab.show()
} }
} }

View file

@ -26,6 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -67,15 +68,18 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun migrate() { override fun migrate() {
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) { if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
logD("Migrating tab setting")
val oldTabs = val oldTabs =
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT)) Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(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. // The playlist tab is now parsed, but it needs to be made visible.
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS } val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
if (playlistIndex > -1) { // Sanity check check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS) oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
} logD("New tabs: $oldTabs")
sharedPreferences.edit { sharedPreferences.edit {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs)) putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
remove(OLD_KEY_LIB_TABS) remove(OLD_KEY_LIB_TABS)
@ -85,8 +89,14 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) { override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) { when (key) {
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged() getString(R.string.set_key_home_tabs) -> {
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged() logD("Dispatching tab setting change")
listener.onTabsChanged()
}
getString(R.string.set_key_hide_collaborators) -> {
logD("Dispatching collaborator setting change")
listener.onHideCollaboratorsChanged()
}
} }
} }

View file

@ -26,7 +26,14 @@ import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions 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.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
@ -68,8 +75,7 @@ constructor(
private val _artistsList = MutableStateFlow(listOf<Artist>()) private val _artistsList = MutableStateFlow(listOf<Artist>())
/** /**
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that * 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 * if "Hide collaborators" is on, this list will not include collaborator [Artist]s.
* [Artist.isCollaborator] is true.
*/ */
val artistsList: MutableStateFlow<List<Artist>> val artistsList: MutableStateFlow<List<Artist>>
get() = _artistsList get() = _artistsList
@ -137,7 +143,6 @@ constructor(
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary val deviceLibrary = musicRepository.deviceLibrary
logD(changes.deviceLibrary)
if (changes.deviceLibrary && deviceLibrary != null) { if (changes.deviceLibrary && deviceLibrary != null) {
logD("Refreshing library") logD("Refreshing library")
// Get the each list of items in the library to use as our list data. // Get the each list of items in the library to use as our list data.
@ -150,9 +155,11 @@ constructor(
_artistsList.value = _artistsList.value =
musicSettings.artistSort.artists( musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) { if (homeSettings.shouldHideCollaborators) {
logD("Filtering collaborator artists")
// Hide Collaborators is enabled, filter out collaborators. // Hide Collaborators is enabled, filter out collaborators.
deviceLibrary.artists.filter { !it.isCollaborator } deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
} else { } else {
logD("Using all artists")
deviceLibrary.artists deviceLibrary.artists
}) })
_genresInstructions.put(UpdateInstructions.Diff) _genresInstructions.put(UpdateInstructions.Diff)
@ -170,12 +177,14 @@ constructor(
override fun onTabsChanged() { override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event. // Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes() currentTabModes = makeTabModes()
logD("Updating tabs: ${currentTabMode.value}")
_shouldRecreate.put(Unit) _shouldRecreate.put(Unit)
} }
override fun onHideCollaboratorsChanged() { override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents // Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update. // of the library, consider it a library update.
logD("Collaborator setting changed, forwarding update")
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) 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]. * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
*/ */
fun setSortForCurrentTab(sort: Sort) { 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. // 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 -> { MusicMode.SONGS -> {
logD("Updating song [$mode] sort mode to $sort")
musicSettings.songSort = sort musicSettings.songSort = sort
_songsInstructions.put(UpdateInstructions.Replace(0)) _songsInstructions.put(UpdateInstructions.Replace(0))
_songsList.value = sort.songs(_songsList.value) _songsList.value = sort.songs(_songsList.value)
} }
MusicMode.ALBUMS -> { MusicMode.ALBUMS -> {
logD("Updating album [$mode] sort mode to $sort")
musicSettings.albumSort = sort musicSettings.albumSort = sort
_albumsInstructions.put(UpdateInstructions.Replace(0)) _albumsInstructions.put(UpdateInstructions.Replace(0))
_albumsLists.value = sort.albums(_albumsLists.value) _albumsLists.value = sort.albums(_albumsLists.value)
} }
MusicMode.ARTISTS -> { MusicMode.ARTISTS -> {
logD("Updating artist [$mode] sort mode to $sort")
musicSettings.artistSort = sort musicSettings.artistSort = sort
_artistsInstructions.put(UpdateInstructions.Replace(0)) _artistsInstructions.put(UpdateInstructions.Replace(0))
_artistsList.value = sort.artists(_artistsList.value) _artistsList.value = sort.artists(_artistsList.value)
} }
MusicMode.GENRES -> { MusicMode.GENRES -> {
logD("Updating genre [$mode] sort mode to $sort")
musicSettings.genreSort = sort musicSettings.genreSort = sort
_genresInstructions.put(UpdateInstructions.Replace(0)) _genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value) _genresList.value = sort.genres(_genresList.value)
} }
MusicMode.PLAYLISTS -> { MusicMode.PLAYLISTS -> {
logD("Updating playlist [$mode] sort mode to $sort")
musicSettings.playlistSort = sort musicSettings.playlistSort = sort
_playlistsInstructions.put(UpdateInstructions.Replace(0)) _playlistsInstructions.put(UpdateInstructions.Replace(0))
_playlistsList.value = sort.playlists(_playlistsList.value) _playlistsList.value = sort.playlists(_playlistsList.value)

View file

@ -33,6 +33,7 @@ import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import com.google.android.material.R as MR
import com.google.android.material.textview.MaterialTextView import com.google.android.material.textview.MaterialTextView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat 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) minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge) TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorCompat(R.attr.colorOnSecondary)) setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER gravity = Gravity.CENTER
includeFontPadding = false includeFontPadding = false
@ -67,7 +68,10 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
private val paint: Paint = private val paint: Paint =
Paint().apply { Paint().apply {
isAntiAlias = true isAntiAlias = true
color = context.getAttrColorCompat(R.attr.colorSecondary).defaultColor color =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
.defaultColor
style = Paint.Style.FILL style = Paint.Style.FILL
} }

View file

@ -37,7 +37,12 @@ import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView 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 * A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of

View file

@ -30,13 +30,18 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel 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.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
@ -78,7 +83,8 @@ class AlbumListFragment :
collectImmediately(homeModel.albumsList, ::updateAlbums) collectImmediately(homeModel.albumsList, ::updateAlbums)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
override fun onDestroyBinding(binding: FragmentHomeListBinding) { override fun onDestroyBinding(binding: FragmentHomeListBinding) {
@ -101,7 +107,7 @@ class AlbumListFragment :
is Sort.Mode.ByArtist -> album.artists[0].name.thumb is Sort.Mode.ByArtist -> album.artists[0].name.thumb
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd) // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
@ -147,9 +153,11 @@ class AlbumListFragment :
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// If an album is playing, highlight it within this adapter. // Only highlight the album if it is currently playing, and if the currently
albumAdapter.setPlaying(parent as? Album, isPlaying) // playing song is also contained within.
val album = (parent as? Album)?.takeIf { song?.album == it }
albumAdapter.setPlaying(album, isPlaying)
} }
/** /**

View file

@ -28,8 +28,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder 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.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
/** /**
@ -78,7 +78,8 @@ class ArtistListFragment :
collectImmediately(homeModel.artistsList, ::updateArtists) collectImmediately(homeModel.artistsList, ::updateArtists)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
override fun onDestroyBinding(binding: FragmentHomeListBinding) { override fun onDestroyBinding(binding: FragmentHomeListBinding) {
@ -121,16 +122,18 @@ class ArtistListFragment :
} }
private fun updateArtists(artists: List<Artist>) { private fun updateArtists(artists: List<Artist>) {
artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) }) artistAdapter.update(artists, homeModel.artistsInstructions.consume())
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// If an artist is playing, highlight it within this adapter. // Only highlight the artist if it is currently playing, and if the currently
artistAdapter.setPlaying(parent as? Artist, isPlaying) // playing song is also contained within.
val artist = (parent as? Artist)?.takeIf { song?.run { artists.contains(it) } ?: false }
artistAdapter.setPlaying(artist, isPlaying)
} }
/** /**

View file

@ -28,8 +28,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.GenreViewHolder 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.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/** /**
* A [ListFragment] that shows a list of [Genre]s. * A [ListFragment] that shows a list of [Genre]s.
@ -77,7 +77,8 @@ class GenreListFragment :
collectImmediately(homeModel.genresList, ::updateGenres) collectImmediately(homeModel.genresList, ::updateGenres)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
override fun onDestroyBinding(binding: FragmentHomeListBinding) { override fun onDestroyBinding(binding: FragmentHomeListBinding) {
@ -120,16 +121,18 @@ class GenreListFragment :
} }
private fun updateGenres(genres: List<Genre>) { private fun updateGenres(genres: List<Genre>) {
genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) }) genreAdapter.update(genres, homeModel.genresInstructions.consume())
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// If a genre is playing, highlight it within this adapter. // Only highlight the genre if it is currently playing, and if the currently
genreAdapter.setPlaying(parent as? Genre, isPlaying) // playing song is also contained within.
val genre = (parent as? Genre)?.takeIf { song?.run { genres.contains(it) } ?: false }
genreAdapter.setPlaying(genre, isPlaying)
} }
/** /**

View file

@ -27,8 +27,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder 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.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/** /**
* A [ListFragment] that shows a list of [Playlist]s. * A [ListFragment] that shows a list of [Playlist]s.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Show a placeholder when there are no playlists.
*/ */
class PlaylistListFragment : class PlaylistListFragment :
ListFragment<Playlist, FragmentHomeListBinding>(), ListFragment<Playlist, FragmentHomeListBinding>(),
@ -77,7 +75,8 @@ class PlaylistListFragment :
collectImmediately(homeModel.playlistsList, ::updatePlaylists) collectImmediately(homeModel.playlistsList, ::updatePlaylists)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
override fun onDestroyBinding(binding: FragmentHomeListBinding) { override fun onDestroyBinding(binding: FragmentHomeListBinding) {
@ -120,17 +119,18 @@ class PlaylistListFragment :
} }
private fun updatePlaylists(playlists: List<Playlist>) { private fun updatePlaylists(playlists: List<Playlist>) {
playlistAdapter.update( playlistAdapter.update(playlists, homeModel.playlistsInstructions.consume())
playlists, homeModel.playlistsInstructions.consume().also { logD(it) })
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// If a playlist is playing, highlight it within this adapter. // Only highlight the playlist if it is currently playing, and if the currently
playlistAdapter.setPlaying(parent as? Playlist, isPlaying) // playing song is also contained within.
val playlist = (parent as? Playlist)?.takeIf { it.songs.contains(song) }
playlistAdapter.setPlaying(playlist, isPlaying)
} }
/** /**

View file

@ -30,8 +30,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
@ -155,12 +155,8 @@ class SongListFragment :
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent == null) { // Only indicate playback that is from all songs
songAdapter.setPlaying(song, isPlaying) songAdapter.setPlaying(song.takeIf { parent == null }, isPlaying)
} else {
// Ignore playback that is not from all songs
songAdapter.setPlaying(null, isPlaying)
}
} }
/** /**

View file

@ -23,7 +23,6 @@ import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logD
/** /**
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations * A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
@ -67,20 +66,11 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
// Use expected sw* size thresholds when choosing a configuration. // Use expected sw* size thresholds when choosing a configuration.
when { when {
// On small screens, only display an icon. // On small screens, only display an icon.
width < 370 -> { width < 370 -> tab.setIcon(icon).setContentDescription(string)
logD("Using icon-only configuration")
tab.setIcon(icon).setContentDescription(string)
}
// On large screens, display an icon and text. // On large screens, display an icon and text.
width < 600 -> { width < 600 -> tab.setText(string)
logD("Using text-only configuration")
tab.setText(string)
}
// On medium-size screens, display text. // On medium-size screens, display text.
else -> { else -> tab.setIcon(icon).setText(string)
logD("Using icon-and-text configuration")
tab.setIcon(icon).setText(string)
}
} }
} }
} }

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
/** /**
* A representation of a library tab suitable for configuration. * A representation of a library tab suitable for configuration.
@ -84,6 +85,10 @@ sealed class Tab(open val mode: MusicMode) {
fun toIntCode(tabs: Array<Tab>): Int { fun toIntCode(tabs: Array<Tab>): Int {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason. // Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.mode } 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 sequence = 0
var shift = MAX_SEQUENCE_IDX * 4 var shift = MAX_SEQUENCE_IDX * 4
@ -127,6 +132,10 @@ sealed class Tab(open val mode: MusicMode) {
// Make sure there are no duplicate tabs // Make sure there are no duplicate tabs
val distinct = tabs.distinctBy { it.mode } 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. // For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) { if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {

View file

@ -28,6 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater 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. * A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
@ -52,6 +53,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param newTabs The new array of tabs to show. * @param newTabs The new array of tabs to show.
*/ */
fun submitTabs(newTabs: Array<Tab>) { fun submitTabs(newTabs: Array<Tab>) {
logD("Force-updating tab information")
tabs = newTabs tabs = newTabs
@Suppress("NotifyDatasetChanged") notifyDataSetChanged() @Suppress("NotifyDatasetChanged") notifyDataSetChanged()
} }
@ -63,6 +65,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param tab The new tab. * @param tab The new tab.
*/ */
fun setTab(at: Int, tab: Tab) { fun setTab(at: Int, tab: Tab) {
logD("Updating tab [at: $at, tab: $tab]")
tabs[at] = tab tabs[at] = tab
// Use a payload to avoid an item change animation. // Use a payload to avoid an item change animation.
notifyItemChanged(at, PAYLOAD_TAB_CHANGED) notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
@ -75,6 +78,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param b The position of the second tab to swap. * @param b The position of the second tab to swap.
*/ */
fun swapTabs(a: Int, b: Int) { fun swapTabs(a: Int, b: Int) {
logD("Swapping tabs [a: $a, b: $b]")
val tmp = tabs[b] val tmp = tabs[b]
tabs[b] = tabs[a] tabs[b] = tabs[a]
tabs[a] = tmp tabs[a] = tmp

View file

@ -91,14 +91,15 @@ class TabCustomizeDialog :
// We will need the exact index of the tab to update on in order to // We will need the exact index of the tab to update on in order to
// notify the adapter of the change. // notify the adapter of the change.
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode } val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
val tab = tabAdapter.tabs[index] val old = tabAdapter.tabs[index]
tabAdapter.setTab( val new =
index, when (old) {
when (tab) {
// Invert the visibility of the tab // Invert the visibility of the tab
is Tab.Visible -> Tab.Invisible(tab.mode) is Tab.Visible -> Tab.Invisible(old.mode)
is Tab.Invisible -> Tab.Visible(tab.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. // 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 = (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =

View file

@ -63,7 +63,9 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
return true 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. // We use a custom drag handle, so disable the long press action.
override fun isLongPressDragEnabled() = false override fun isLongPressDragEnabled() = false

View file

@ -27,7 +27,6 @@ import coil.request.ImageRequest
import coil.size.Size import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
/** /**
@ -97,16 +96,11 @@ constructor(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(listOf(song)) .data(listOf(song))
// Use ORIGINAL sizing, as we are not loading into any View-like component. // Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL) .size(Size.ORIGINAL))
.transformations(SquareFrameTransform.INSTANCE))
// Override the target in order to deliver the bitmap to the given
// listener.
.target( .target(
onSuccess = { onSuccess = {
synchronized(this) { synchronized(this) {
if (currentHandle == handle) { if (currentHandle == handle) {
// Has not been superseded by a new request, can deliver
// this result.
target.onCompleted(it.toBitmap()) target.onCompleted(it.toBitmap())
} }
} }
@ -114,8 +108,6 @@ constructor(
onError = { onError = {
synchronized(this) { synchronized(this) {
if (currentHandle == handle) { if (currentHandle == handle) {
// Has not been superseded by a new request, can deliver
// this result.
target.onCompleted(null) target.onCompleted(null)
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>, 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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}

View file

@ -22,7 +22,6 @@ import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.image.extractor.*
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View file

@ -73,6 +73,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) { override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
if (key == getString(R.string.set_key_cover_mode)) { if (key == getString(R.string.set_key_cover_mode)) {
logD("Dispatching cover mode setting change")
listener.onCoverModeChanged() listener.onCoverModeChanged()
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, Int> {
// 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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>? = 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<Song>, 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
}
}

View file

@ -24,12 +24,12 @@ import coil.key.Keyer
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.Song
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
Keyer<List<Song>> { Keyer<List<Song>> {
override fun key(data: List<Song>, options: Options) = override fun key(data: List<Song>, options: Options) =
"${coverExtractor.computeAlbumOrdering(data).hashCode()}" "${coverExtractor.computeCoverOrdering(data).hashCode()}"
} }
class SongCoverFetcher class SongCoverFetcher

View file

@ -43,6 +43,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -50,11 +51,16 @@ import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor class CoverExtractor
@Inject @Inject
constructor( constructor(
@ -62,28 +68,69 @@ constructor(
private val imageSettings: ImageSettings, private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory 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<Song>, size: Size): FetchResult? { suspend fun extract(songs: List<Song>, size: Size): FetchResult? {
val albums = computeAlbumOrdering(songs) val albums = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
for (album in albums) { 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) { 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 -> // Not enough covers for a mosaic, take the first one (if that even exists)
SourceResult( val first = streams.firstOrNull() ?: return null
source = ImageSource(stream.source().buffer(), context),
mimeType = null, // All but the first stream will be unused, free their resources
dataSource = DataSource.DISK) 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<Song>) = /**
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<Song>): List<Album> {
// 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<Album, Int>(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 { try {
when (imageSettings.coverMode) { when (imageSettings.coverMode) {
CoverMode.OFF -> null CoverMode.OFF -> null
@ -91,7 +138,7 @@ constructor(
CoverMode.QUALITY -> extractQualityCover(album) CoverMode.QUALITY -> extractQualityCover(album)
} }
} catch (e: Exception) { } 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 null
} }
@ -148,7 +195,6 @@ constructor(
} }
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) { if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
logD("Front cover found")
stream = ByteArrayInputStream(pic) stream = ByteArrayInputStream(pic)
break break
} else if (stream == null) { } else if (stream == null) {
@ -164,7 +210,7 @@ constructor(
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) } withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult { private fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic. // Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize = val mosaicFrameSize =
@ -184,10 +230,9 @@ constructor(
break break
} }
// Run the bitmap through a transform to reflect the configuration of other images. // Crop the bitmap down to a square so it leaves no empty space
val bitmap = // TODO: Work around this
SquareFrameTransform.INSTANCE.transform( val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize)
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width x += bitmap.width
@ -206,14 +251,27 @@ constructor(
dataSource = DataSource.DISK) 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 { 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 } val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size 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
}
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.list
import androidx.annotation.StringRes 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. */ /** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
interface Item interface Item

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.list package org.oxycblt.auxio.list
import android.view.MenuItem
import android.view.View import android.view.View
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
@ -28,10 +27,17 @@ import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.selection.SelectionFragment 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.MainNavigationAction
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -83,32 +89,45 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) { protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
logD("Launching new song menu: ${song.name}") logD("Launching new song menu: ${song.name}")
openMusicMenuImpl(anchor, menuRes) { openMenu(anchor, menuRes) {
when (it.itemId) { setOnMenuItemClickListener {
R.id.action_play_next -> { when (it.itemId) {
playbackModel.playNext(song) R.id.action_play_next -> {
requireContext().showToast(R.string.lng_queue_added) playbackModel.playNext(song)
} requireContext().showToast(R.string.lng_queue_added)
R.id.action_queue_add -> { true
playbackModel.addToQueue(song) }
requireContext().showToast(R.string.lng_queue_added) R.id.action_queue_add -> {
} playbackModel.addToQueue(song)
R.id.action_go_artist -> { requireContext().showToast(R.string.lng_queue_added)
navModel.exploreNavigateToParentArtist(song) true
} }
R.id.action_go_album -> { R.id.action_go_artist -> {
navModel.exploreNavigateTo(song.album) navModel.exploreNavigateToParentArtist(song)
} true
R.id.action_playlist_add -> { }
musicModel.addToPlaylist(song) R.id.action_go_album -> {
} navModel.exploreNavigateTo(song.album)
R.id.action_song_detail -> { true
navModel.mainNavigateTo( }
MainNavigationAction.Directions( R.id.action_share -> {
MainFragmentDirections.actionShowDetails(song.uid))) requireContext().share(song)
} true
else -> { }
error("Unexpected menu item selected") 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<in T : Music, VB : ViewBinding> :
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) { protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
logD("Launching new album menu: ${album.name}") logD("Launching new album menu: ${album.name}")
openMusicMenuImpl(anchor, menuRes) { openMenu(anchor, menuRes) {
when (it.itemId) { setOnMenuItemClickListener {
R.id.action_play -> { when (it.itemId) {
playbackModel.play(album) R.id.action_play -> {
} playbackModel.play(album)
R.id.action_shuffle -> { true
playbackModel.shuffle(album) }
} R.id.action_shuffle -> {
R.id.action_play_next -> { playbackModel.shuffle(album)
playbackModel.playNext(album) true
requireContext().showToast(R.string.lng_queue_added) }
} R.id.action_play_next -> {
R.id.action_queue_add -> { playbackModel.playNext(album)
playbackModel.addToQueue(album) requireContext().showToast(R.string.lng_queue_added)
requireContext().showToast(R.string.lng_queue_added) true
} }
R.id.action_go_artist -> { R.id.action_queue_add -> {
navModel.exploreNavigateToParentArtist(album) playbackModel.addToQueue(album)
} requireContext().showToast(R.string.lng_queue_added)
R.id.action_playlist_add -> { true
musicModel.addToPlaylist(album) }
} R.id.action_go_artist -> {
else -> { navModel.exploreNavigateToParentArtist(album)
error("Unexpected menu item selected") 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<in T : Music, VB : ViewBinding> :
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) { protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
logD("Launching new artist menu: ${artist.name}") logD("Launching new artist menu: ${artist.name}")
openMusicMenuImpl(anchor, menuRes) { openMenu(anchor, menuRes) {
when (it.itemId) { val playable = artist.songs.isNotEmpty()
R.id.action_play -> { if (!playable) {
playbackModel.play(artist) logD("Artist is empty, disabling playback/playlist/share options")
} }
R.id.action_shuffle -> { menu.findItem(R.id.action_play).isEnabled = playable
playbackModel.shuffle(artist) menu.findItem(R.id.action_shuffle).isEnabled = playable
} menu.findItem(R.id.action_play_next).isEnabled = playable
R.id.action_play_next -> { menu.findItem(R.id.action_queue_add).isEnabled = playable
playbackModel.playNext(artist) menu.findItem(R.id.action_playlist_add).isEnabled = playable
requireContext().showToast(R.string.lng_queue_added) menu.findItem(R.id.action_share).isEnabled = playable
}
R.id.action_queue_add -> { setOnMenuItemClickListener {
playbackModel.addToQueue(artist) when (it.itemId) {
requireContext().showToast(R.string.lng_queue_added) R.id.action_play -> {
} playbackModel.play(artist)
R.id.action_playlist_add -> { true
musicModel.addToPlaylist(artist) }
} R.id.action_shuffle -> {
else -> { playbackModel.shuffle(artist)
error("Unexpected menu item selected") 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<in T : Music, VB : ViewBinding> :
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) { protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
logD("Launching new genre menu: ${genre.name}") logD("Launching new genre menu: ${genre.name}")
openMusicMenuImpl(anchor, menuRes) { openMenu(anchor, menuRes) {
when (it.itemId) { setOnMenuItemClickListener {
R.id.action_play -> { when (it.itemId) {
playbackModel.play(genre) R.id.action_play -> {
} playbackModel.play(genre)
R.id.action_shuffle -> { true
playbackModel.shuffle(genre) }
} R.id.action_shuffle -> {
R.id.action_play_next -> { playbackModel.shuffle(genre)
playbackModel.playNext(genre) true
requireContext().showToast(R.string.lng_queue_added) }
} R.id.action_play_next -> {
R.id.action_queue_add -> { playbackModel.playNext(genre)
playbackModel.addToQueue(genre) requireContext().showToast(R.string.lng_queue_added)
requireContext().showToast(R.string.lng_queue_added) true
} }
R.id.action_playlist_add -> { R.id.action_queue_add -> {
musicModel.addToPlaylist(genre) playbackModel.addToQueue(genre)
} requireContext().showToast(R.string.lng_queue_added)
else -> { true
error("Unexpected menu item selected") }
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<in T : Music, VB : ViewBinding> :
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) { protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
logD("Launching new playlist menu: ${playlist.name}") 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) { openMenu(anchor, menuRes) {
setOnMenuItemClickListener { item -> val playable = playlist.songs.isNotEmpty()
onMenuItemClick(item) menu.findItem(R.id.action_play).isEnabled = playable
true 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<in T : Music, VB : ViewBinding> :
return return
} }
logD("Opening popup menu menu")
currentMenu = currentMenu =
PopupMenu(requireContext(), anchor).apply { PopupMenu(requireContext(), anchor).apply {
inflate(menuRes) inflate(menuRes)

View file

@ -22,8 +22,13 @@ import androidx.annotation.IdRes
import kotlin.math.max import kotlin.math.max
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.* 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.Date
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc

View file

@ -20,10 +20,12 @@ package org.oxycblt.auxio.list.adapter
import android.os.Handler import android.os.Handler
import android.os.Looper 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.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor import java.util.concurrent.Executor
import org.oxycblt.auxio.util.logD
/** /**
* A variant of ListDiffer with more flexible updates. * A variant of ListDiffer with more flexible updates.
@ -45,15 +47,18 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
/** /**
* Update the adapter with new data. * 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 instructions The [UpdateInstructions] to visually update the list with.
* @param callback Called when the update is completed. May be done asynchronously. * @param callback Called when the update is completed. May be done asynchronously.
*/ */
fun update( fun update(
newData: List<T>, newList: List<T>,
instructions: UpdateInstructions?, instructions: UpdateInstructions?,
callback: (() -> Unit)? = null 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<T>(
) { ) {
// fast simple remove all // fast simple remove all
if (newList.isEmpty()) { if (newList.isEmpty()) {
logD("Short-circuiting diff to remove all")
val countRemoved = oldList.size val countRemoved = oldList.size
currentList = emptyList() currentList = emptyList()
// notify last, after list is updated // notify last, after list is updated
@ -174,6 +180,7 @@ private class FlexibleListDiffer<T>(
// fast simple first insert // fast simple first insert
if (oldList.isEmpty()) { if (oldList.isEmpty()) {
logD("Short-circuiting diff to insert all")
currentList = newList currentList = newList
// notify last, after list is updated // notify last, after list is updated
updateCallback.onInserted(0, newList.size) updateCallback.onInserted(0, newList.size)
@ -232,8 +239,10 @@ private class FlexibleListDiffer<T>(
throw AssertionError() throw AssertionError()
} }
}) })
mainThreadExecutor.execute { mainThreadExecutor.execute {
if (maxScheduledGeneration == runGeneration) { if (maxScheduledGeneration == runGeneration) {
logD("Applying calculated diff")
currentList = newList currentList = newList
result.dispatchUpdatesTo(updateCallback) result.dispatchUpdatesTo(updateCallback)
callback?.invoke() callback?.invoke()

View file

@ -22,6 +22,7 @@ import android.view.View
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.logD 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. * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
@ -58,6 +59,8 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
* @param isPlaying Whether playback is ongoing or paused. * @param isPlaying Whether playback is ongoing or paused.
*/ */
fun setPlaying(item: T?, isPlaying: Boolean) { fun setPlaying(item: T?, isPlaying: Boolean) {
logD("Updating playing item [old: $currentItem new: $item]")
var updatedItem = false var updatedItem = false
if (currentItem != item) { if (currentItem != item) {
val oldItem = currentItem val oldItem = currentItem
@ -69,7 +72,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else { } else {
logD("oldItem was not in adapter data") logW("oldItem was not in adapter data")
} }
} }
@ -79,7 +82,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else { } else {
logD("newItem was not in adapter data") logW("newItem was not in adapter data")
} }
} }
@ -97,7 +100,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else { } else {
logD("newItem was not in adapter data") logW("newItem was not in adapter data")
} }
} }
} }

View file

@ -22,6 +22,7 @@ import android.view.View
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Music 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 * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
@ -54,6 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
// Nothing to do. // Nothing to do.
return return
} }
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
selectedItems = newSelectedItems selectedItems = newSelectedItems
for (i in currentList.indices) { for (i in currentList.indices) {

View file

@ -29,6 +29,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.DialogRecyclerView.ViewHolder
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
/** /**

View file

@ -26,6 +26,7 @@ import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getInteger import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD 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. // this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator. // TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting item") logD("Lifting ViewHolder")
val bg = holder.background val bg = holder.background
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) 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 // This function can be called multiple times, so only start the animation when the view's
// translationZ is already non-zero. // translationZ is already non-zero.
if (holder.root.translationZ != 0f) { if (holder.root.translationZ != 0f) {
logD("Dropping item") logD("Lifting ViewHolder")
val bg = holder.background val bg = holder.background
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) 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. // Long-press events are too buggy, only allow dragging with the handle.
final override fun isLongPressDragEnabled() = false final override fun isLongPressDragEnabled() = false
/** Required [RecyclerView.ViewHolder] implementation that exposes the following. */ /** Required [RecyclerView.ViewHolder] implementation that exposes required fields */
interface ViewHolder { interface ViewHolder {
/** Whether this [ViewHolder] can be moved right now. */ /** Whether this [ViewHolder] can be moved right now. */
val enabled: Boolean val enabled: Boolean

View file

@ -31,11 +31,16 @@ import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater 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. * 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying binding.songAlbumCover.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {
@ -109,7 +114,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying binding.parentImage.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {
@ -169,7 +174,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying binding.parentImage.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {
@ -226,7 +231,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying binding.parentImage.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {
@ -283,7 +288,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying binding.parentImage.setPlaying(isPlaying)
} }
override fun updateSelectionIndicator(isSelected: Boolean) { override fun updateSelectionIndicator(isSelected: Boolean) {
@ -325,7 +330,6 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
* @param basicHeader The new [BasicHeader] to bind. * @param basicHeader The new [BasicHeader] to bind.
*/ */
fun bind(basicHeader: BasicHeader) { fun bind(basicHeader: BasicHeader) {
logD(binding.context.getString(basicHeader.titleRes))
binding.title.text = binding.context.getString(basicHeader.titleRes) binding.title.text = binding.context.getString(basicHeader.titleRes)
} }

View file

@ -26,6 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -79,6 +80,10 @@ abstract class SelectionFragment<VB : ViewBinding> :
playbackModel.shuffle(selectionModel.take()) playbackModel.shuffle(selectionModel.take())
true true
} }
R.id.action_selection_share -> {
requireContext().share(selectionModel.take())
true
}
else -> false else -> false
} }
} }

View file

@ -23,7 +23,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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. * A [ViewModel] that manages the current selection.
@ -76,10 +85,19 @@ constructor(
* @param music The [Music] item to select. * @param music The [Music] item to select.
*/ */
fun select(music: Music) { fun select(music: Music) {
if (music is MusicParent && music.songs.isEmpty()) {
logD("Cannot select empty parent, ignoring operation")
return
}
val selected = _selected.value.toMutableList() val selected = _selected.value.toMutableList()
if (!selected.remove(music)) { if (!selected.remove(music)) {
logD("Adding $music to selection")
selected.add(music) selected.add(music)
} else {
logD("Removed $music from selection")
} }
_selected.value = selected _selected.value = selected
} }
@ -88,8 +106,9 @@ constructor(
* *
* @return A list of [Song]s collated from each item selected. * @return A list of [Song]s collated from each item selected.
*/ */
fun take() = fun take(): List<Song> {
_selected.value logD("Taking selection")
return _selected.value
.flatMap { .flatMap {
when (it) { when (it) {
is Song -> listOf(it) is Song -> listOf(it)
@ -99,12 +118,16 @@ constructor(
is Playlist -> it.songs is Playlist -> it.songs
} }
} }
.also { drop() } .also { _selected.value = listOf() }
}
/** /**
* Clear the current selection. * Clear the current selection.
* *
* @return true if the prior selection was non-empty, false otherwise. * @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() }
}
} }

View file

@ -314,21 +314,23 @@ interface Album : MusicParent {
*/ */
interface Artist : MusicParent { interface Artist : MusicParent {
/** /**
* All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist * All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums].
* will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus * Note that any [Song] credited to this artist will have it's [Album] considered to be
* included in this list. * "indirectly" linked to this [Artist], and thus included in this list.
*/ */
val albums: List<Album> val albums: List<Album>
/** Albums directly credited to this [Artist] via a "Album Artist" tag. */
val explicitAlbums: List<Album>
/** Albums indirectly credited to this [Artist] via an "Artist" tag. */
val implicitAlbums: List<Album>
/** /**
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no * The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
* songs. * songs.
*/ */
val durationMs: Long? 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. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -339,8 +341,6 @@ interface Artist : MusicParent {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Genre : MusicParent { interface Genre : MusicParent {
/** The albums indirectly linked to by the [Song]s of this [Genre]. */
val albums: List<Album>
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */ /** The artists indirectly linked to by the [Artist]s of this [Genre]. */
val artists: List<Artist> val artists: List<Artist>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
@ -353,8 +353,6 @@ interface Genre : MusicParent {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Playlist : MusicParent { interface Playlist : MusicParent {
/** The albums indirectly linked to by the [Song]s of this [Playlist]. */
val albums: List<Album>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
} }

View file

@ -21,12 +21,18 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.util.* import java.util.LinkedList
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext 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.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong 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]. * music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Switch listeners to set when you can confirm there are no order-dependent listener
* configurations
*/ */
interface MusicRepository { interface MusicRepository {
/** The current music information found on the device. */ /** The current music information found on the device. */
@ -230,24 +239,32 @@ constructor(
@Synchronized @Synchronized
override fun addUpdateListener(listener: MusicRepository.UpdateListener) { override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
logD("Adding $listener to update listeners")
updateListeners.add(listener) updateListeners.add(listener)
listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true)) listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
} }
@Synchronized @Synchronized
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { 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 @Synchronized
override fun addIndexingListener(listener: MusicRepository.IndexingListener) { override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
logD("Adding $listener to indexing listeners")
indexingListeners.add(listener) indexingListeners.add(listener)
listener.onIndexingStateChanged() listener.onIndexingStateChanged()
} }
@Synchronized @Synchronized
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { 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 @Synchronized
@ -256,6 +273,7 @@ constructor(
logW("Worker is already registered") logW("Worker is already registered")
return return
} }
logD("Registering worker $worker")
indexingWorker = worker indexingWorker = worker
if (indexingState == null) { if (indexingState == null) {
worker.requestIndex(true) worker.requestIndex(true)
@ -268,6 +286,7 @@ constructor(
logW("Given worker did not match current worker") logW("Given worker did not match current worker")
return return
} }
logD("Unregistering worker $worker")
indexingWorker = null indexingWorker = null
currentIndexingState = null currentIndexingState = null
} }
@ -279,44 +298,42 @@ constructor(
override suspend fun createPlaylist(name: String, songs: List<Song>) { override suspend fun createPlaylist(name: String, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Creating playlist $name with ${songs.size} songs")
userLibrary.createPlaylist(name, songs) userLibrary.createPlaylist(name, songs)
notifyUserLibraryChange() emitLibraryChange(device = false, user = true)
} }
override suspend fun renamePlaylist(playlist: Playlist, name: String) { override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val userLibrary = synchronized(this) { userLibrary ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Renaming $playlist to $name")
userLibrary.renamePlaylist(playlist, name) userLibrary.renamePlaylist(playlist, name)
notifyUserLibraryChange() emitLibraryChange(device = false, user = true)
} }
override suspend fun deletePlaylist(playlist: Playlist) { override suspend fun deletePlaylist(playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Deleting $playlist")
userLibrary.deletePlaylist(playlist) userLibrary.deletePlaylist(playlist)
notifyUserLibraryChange() emitLibraryChange(device = false, user = true)
} }
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) { override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Adding ${songs.size} songs to $playlist")
userLibrary.addToPlaylist(playlist, songs) userLibrary.addToPlaylist(playlist, songs)
notifyUserLibraryChange() emitLibraryChange(device = false, user = true)
} }
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) { override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
logD("Rewriting $playlist with ${songs.size} songs")
userLibrary.rewritePlaylist(playlist, songs) userLibrary.rewritePlaylist(playlist, songs)
notifyUserLibraryChange() emitLibraryChange(device = false, user = true)
}
@Synchronized
private fun notifyUserLibraryChange() {
for (listener in updateListeners) {
listener.onMusicChanges(
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
}
} }
@Synchronized @Synchronized
override fun requestIndex(withCache: Boolean) { override fun requestIndex(withCache: Boolean) {
logD("Requesting index operation [cache=$withCache]")
indexingWorker?.requestIndex(withCache) indexingWorker?.requestIndex(withCache)
} }
@ -343,7 +360,7 @@ constructor(
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) { PackageManager.PERMISSION_DENIED) {
logE("Permission check failed") logE("Permissions were not granted")
// No permissions, signal that we can't do anything. // No permissions, signal that we can't do anything.
throw NoAudioPermissionException() throw NoAudioPermissionException()
} }
@ -353,14 +370,16 @@ constructor(
emitLoading(IndexingProgress.Indeterminate) emitLoading(IndexingProgress.Indeterminate)
// Do the initial query of the cache and media databases in parallel. // 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 mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() }
val cache = val cache =
if (withCache) { if (withCache) {
logD("Reading cache")
cacheRepository.readCache() cacheRepository.readCache()
} else { } else {
null null
} }
logD("Awaiting MediaStore query")
val query = mediaStoreQueryJob.await().getOrThrow() val query = mediaStoreQueryJob.await().getOrThrow()
// Now start processing the queried song information in parallel. Songs that can't be // Now start processing the queried song information in parallel. Songs that can't be
@ -369,11 +388,13 @@ constructor(
logD("Starting song discovery") logD("Starting song discovery")
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
logD("Started MediaStore discovery")
val mediaStoreJob = val mediaStoreJob =
worker.scope.tryAsync { worker.scope.tryAsync {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
incompleteSongs.close() incompleteSongs.close()
} }
logD("Started ExoPlayer discovery")
val metadataJob = val metadataJob =
worker.scope.tryAsync { worker.scope.tryAsync {
tagExtractor.consume(incompleteSongs, completeSongs) tagExtractor.consume(incompleteSongs, completeSongs)
@ -386,7 +407,8 @@ constructor(
rawSongs.add(rawSong) rawSongs.add(rawSong)
emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) 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() mediaStoreJob.await().getOrThrow()
metadataJob.await().getOrThrow() metadataJob.await().getOrThrow()
@ -401,25 +423,47 @@ constructor(
// TODO: Indicate playlist state in loading process? // TODO: Indicate playlist state in loading process?
emitLoading(IndexingProgress.Indeterminate) emitLoading(IndexingProgress.Indeterminate)
val deviceLibraryChannel = Channel<DeviceLibrary>() val deviceLibraryChannel = Channel<DeviceLibrary>()
logD("Starting DeviceLibrary creation")
val deviceLibraryJob = val deviceLibraryJob =
worker.scope.tryAsync(Dispatchers.Main) { worker.scope.tryAsync(Dispatchers.Default) {
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
} }
logD("Starting UserLibrary creation")
val userLibraryJob = val userLibraryJob =
worker.scope.tryAsync { worker.scope.tryAsync {
userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() } userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() }
} }
if (cache == null || cache.invalidated) { if (cache == null || cache.invalidated) {
logD("Writing cache [why=${cache?.invalidated}]")
cacheRepository.writeCache(rawSongs) cacheRepository.writeCache(rawSongs)
} }
logD("Awaiting library creation")
val deviceLibrary = deviceLibraryJob.await().getOrThrow() val deviceLibrary = deviceLibraryJob.await().getOrThrow()
val userLibrary = userLibraryJob.await().getOrThrow() val userLibrary = userLibraryJob.await().getOrThrow()
withContext(Dispatchers.Main) {
emitComplete(null) logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]")
emitData(deviceLibrary, 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 <R> CoroutineScope.tryAsync( private inline fun <R> CoroutineScope.tryAsync(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
crossinline block: suspend () -> R crossinline block: suspend () -> R
@ -447,6 +491,7 @@ constructor(
synchronized(this) { synchronized(this) {
previousCompletedState = IndexingState.Completed(error) previousCompletedState = IndexingState.Completed(error)
currentIndexingState = null currentIndexingState = null
logD("Dispatching completion state [error=$error]")
for (listener in indexingListeners) { for (listener in indexingListeners) {
listener.onIndexingStateChanged() listener.onIndexingStateChanged()
} }
@ -454,14 +499,9 @@ constructor(
} }
@Synchronized @Synchronized
private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) { private fun emitLibraryChange(device: Boolean, user: Boolean) {
val deviceLibraryChanged = this.deviceLibrary != deviceLibrary val changes = MusicRepository.Changes(device, user)
val userLibraryChanged = this.userLibrary != userLibrary logD("Dispatching library change [changes=$changes]")
if (!deviceLibraryChanged && !userLibraryChanged) return
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged)
for (listener in updateListeners) { for (listener in updateListeners) {
listener.onMusicChanges(changes) listener.onMusicChanges(changes)
} }

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.fs.Directory
import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.music.fs.MusicDirectories
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
/** /**
* User configuration specific to music system. * 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),
getString(R.string.set_key_music_dirs_include), getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators), getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged() getString(R.string.set_key_auto_sort_names) -> {
getString(R.string.set_key_observing) -> listener.onObservingChanged() logD("Dispatching indexing setting change for $key")
listener.onIndexingSettingChanged()
}
getString(R.string.set_key_observing) -> {
logD("Dispatching observing setting change")
listener.onObservingChanged()
}
} }
} }
} }

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
/** /**
* A [ViewModel] providing data specific to the music loading process. * A [ViewModel] providing data specific to the music loading process.
@ -89,6 +90,7 @@ constructor(
deviceLibrary.artists.size, deviceLibrary.artists.size,
deviceLibrary.genres.size, deviceLibrary.genres.size,
deviceLibrary.songs.sumOf { it.durationMs }) deviceLibrary.songs.sumOf { it.durationMs })
logD("Updated statistics: ${_statistics.value}")
} }
override fun onIndexingStateChanged() { override fun onIndexingStateChanged() {
@ -97,11 +99,13 @@ constructor(
/** Requests that the music library should be re-loaded while leveraging the cache. */ /** Requests that the music library should be re-loaded while leveraging the cache. */
fun refresh() { fun refresh() {
logD("Refreshing library")
musicRepository.requestIndex(true) musicRepository.requestIndex(true)
} }
/** Requests that the music library be re-loaded without the cache. */ /** Requests that the music library be re-loaded without the cache. */
fun rescan() { fun rescan() {
logD("Rescanning library")
musicRepository.requestIndex(false) musicRepository.requestIndex(false)
} }
@ -113,8 +117,10 @@ constructor(
*/ */
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) { fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
if (name != null) { if (name != null) {
logD("Creating $name with ${songs.size} songs]")
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
} else { } else {
logD("Launching creation dialog for ${songs.size} songs")
_newPlaylistSongs.put(songs) _newPlaylistSongs.put(songs)
} }
} }
@ -127,8 +133,10 @@ constructor(
*/ */
fun renamePlaylist(playlist: Playlist, name: String? = null) { fun renamePlaylist(playlist: Playlist, name: String? = null) {
if (name != null) { if (name != null) {
logD("Renaming $playlist to $name")
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
} else { } else {
logD("Launching rename dialog for $playlist")
_playlistToRename.put(playlist) _playlistToRename.put(playlist)
} }
} }
@ -142,8 +150,10 @@ constructor(
*/ */
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
if (rude) { if (rude) {
logD("Deleting $playlist")
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
} else { } else {
logD("Launching deletion dialog for $playlist")
_playlistToDelete.put(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. * @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/ */
fun addToPlaylist(song: Song, playlist: Playlist? = null) { fun addToPlaylist(song: Song, playlist: Playlist? = null) {
logD("Adding $song to playlist")
addToPlaylist(listOf(song), 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. * @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/ */
fun addToPlaylist(album: Album, playlist: Playlist? = null) { fun addToPlaylist(album: Album, playlist: Playlist? = null) {
logD("Adding $album to playlist")
addToPlaylist(musicSettings.albumSongSort.songs(album.songs), 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. * @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/ */
fun addToPlaylist(artist: Artist, playlist: Playlist? = null) { fun addToPlaylist(artist: Artist, playlist: Playlist? = null) {
logD("Adding $artist to playlist")
addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), 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. * @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/ */
fun addToPlaylist(genre: Genre, playlist: Playlist? = null) { fun addToPlaylist(genre: Genre, playlist: Playlist? = null) {
logD("Adding $genre to playlist")
addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist) addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist)
} }
@ -196,8 +210,10 @@ constructor(
*/ */
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) { fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
if (playlist != null) { if (playlist != null) {
logD("Adding ${songs.size} songs to $playlist")
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
} else { } else {
logD("Launching addition dialog for songs=${songs.size}")
_songsToAdd.put(songs) _songsToAdd.put(songs)
} }
} }

View file

@ -20,7 +20,8 @@ package org.oxycblt.auxio.music.cache
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong 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. * 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 { try {
// Faster to load the whole database into memory than do a query on each // Faster to load the whole database into memory than do a query on each
// populate call. // populate call.
CacheImpl(cachedSongsDao.readSongs()) val songs = cachedSongsDao.readSongs()
logD("Successfully read ${songs.size} songs from cache")
CacheImpl(songs)
} catch (e: Exception) { } catch (e: Exception) {
logE("Unable to load cache database.") logE("Unable to load cache database.")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
@ -60,7 +63,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached
try { try {
// Still write out whatever data was extracted. // Still write out whatever data was extracted.
cachedSongsDao.nukeSongs() cachedSongsDao.nukeSongs()
logD("Successfully deleted old cache")
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw)) cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
logD("Successfully wrote ${rawSongs.size} songs to cache")
} catch (e: Exception) { } catch (e: Exception) {
logE("Unable to save cache database.") logE("Unable to save cache database.")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
@ -96,7 +101,6 @@ private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
override var invalidated = false override var invalidated = false
override fun populate(rawSong: RawSong): Boolean { override fun populate(rawSong: RawSong): Boolean {
// For a cached raw song to be used, it must exist within the cache and have matching // For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't // addition and modification timestamps. Technically the addition timestamp doesn't
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we // exist, but to safeguard against possible OEM-specific timestamp incoherence, we

View file

@ -23,7 +23,13 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.list.Sort 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.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery import org.oxycblt.auxio.music.fs.useQuery
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -128,20 +134,11 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } } private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } } private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
override fun equals(other: Any?) = // All other music is built from songs, so comparison only needs to check songs.
other is DeviceLibrary && override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
other.songs == songs && override fun hashCode() = songs.hashCode()
other.albums == albums && override fun toString() =
other.artists == artists && "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, artists=${artists.size}, genres=${genres.size})"
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
}
override fun findSong(uid: Music.UID) = songUidMap[uid] override fun findSong(uid: Music.UID) = songUidMap[uid]
override fun findAlbum(uid: Music.UID) = albumUidMap[uid] override fun findAlbum(uid: Music.UID) = albumUidMap[uid]
@ -160,100 +157,69 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
songs.find { it.path.name == displayName && it.size == size } songs.find { it.path.name == displayName && it.size == size }
} }
/** private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings): List<SongImpl> {
* Build a list [SongImpl]s from the given [RawSong]. val start = System.currentTimeMillis()
* val songs =
* @param rawSongs The [RawSong]s to build the [SongImpl]s from. Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
* @param settings [MusicSettings] to obtain user parsing configuration. .songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid })
* @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
* grouping. return songs
*/ }
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid })
/**
* 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<SongImpl>, settings: MusicSettings): List<AlbumImpl> { private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
val start = System.currentTimeMillis()
// Group songs by their singular raw album, then map the raw instances and their // 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. // grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it.rawAlbum } val songsByAlbum = songs.groupBy { it.rawAlbum.key }
val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) } val albums = songsByAlbum.map { AlbumImpl(it.key.value, settings, it.value) }
logD("Successfully built ${albums.size} albums") logD("Successfully built ${albums.size} albums in ${System.currentTimeMillis() - start}ms")
return albums 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( private fun buildArtists(
songs: List<SongImpl>, songs: List<SongImpl>,
albums: List<AlbumImpl>, albums: List<AlbumImpl>,
settings: MusicSettings settings: MusicSettings
): List<ArtistImpl> { ): List<ArtistImpl> {
val start = System.currentTimeMillis()
// Add every raw artist credited to each Song/Album to the grouping. This way, // Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists. // different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<RawArtist, MutableList<Music>>() // Songs and albums are grouped by artist and album artist respectively.
val musicByArtist = mutableMapOf<RawArtist.Key, MutableList<Music>>()
for (song in songs) { for (song in songs) {
for (rawArtist in song.rawArtists) { for (rawArtist in song.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(song)
} }
} }
for (album in albums) { for (album in albums) {
for (rawArtist in album.rawArtists) { 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. // Convert the combined mapping into artist instances.
val artists = musicByArtist.map { ArtistImpl(it.key, settings, it.value) } val artists = musicByArtist.map { ArtistImpl(it.key.value, settings, it.value) }
logD("Successfully built ${artists.size} artists") logD(
"Successfully built ${artists.size} artists in ${System.currentTimeMillis() - start}ms")
return artists 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<SongImpl>, settings: MusicSettings): List<GenreImpl> { private fun buildGenres(songs: List<SongImpl>, settings: MusicSettings): List<GenreImpl> {
val start = System.currentTimeMillis()
// Add every raw genre credited to each Song to the grouping. This way, // Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres. // different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<RawGenre, MutableList<SongImpl>>() val songsByGenre = mutableMapOf<RawGenre.Key, MutableList<SongImpl>>()
for (song in songs) { for (song in songs) {
for (rawGenre in song.rawGenres) { for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) songsByGenre.getOrPut(rawGenre.key) { mutableListOf() }.add(song)
} }
} }
// Convert the mapping into genre instances. // Convert the mapping into genre instances.
val genres = songsByGenre.map { GenreImpl(it.key, settings, it.value) } val genres = songsByGenre.map { GenreImpl(it.key.value, settings, it.value) }
logD("Successfully built ${genres.size} genres") logD("Successfully built ${genres.size} genres in ${System.currentTimeMillis() - start}ms")
return genres return genres
} }
} }

View file

@ -26,5 +26,5 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface DeviceModule { interface DeviceModule {
@Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory @Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
} }

View file

@ -20,13 +20,21 @@ package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort 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.MimeType
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.fs.toCoverUri 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.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.parseId3GenreNames
import org.oxycblt.auxio.music.metadata.parseMultiValue import org.oxycblt.auxio.music.metadata.parseMultiValue
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
@ -85,9 +93,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
override val album: Album override val album: Album
get() = unlikelyToBeNull(_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?) = override fun equals(other: Any?) =
other is SongImpl && uid == other.uid && rawSong == other.rawSong 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 artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings) private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
@ -237,44 +248,61 @@ class AlbumImpl(
update(rawAlbum.rawArtists.map { it.name }) update(rawAlbum.rawArtists.map { it.name })
} }
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings) override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
override val dates: Date.Range?
override val dates = Date.Range.from(songs.mapNotNull { it.date })
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = rawAlbum.mediaStoreId.toCoverUri() override val coverUri = rawAlbum.mediaStoreId.toCoverUri()
override val durationMs: Long override val durationMs: Long
override val dateAdded: 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<ArtistImpl>() private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist> override val artists: List<Artist>
get() = _artists get() = _artists
private var hashCode = uid.hashCode()
init { init {
var totalDuration: Long = 0 var totalDuration: Long = 0
var minDate: Date? = null
var maxDate: Date? = null
var earliestDateAdded: Long = Long.MAX_VALUE var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency. // Do linking and value generation in the same loop for efficiency.
for (song in songs) { for (song in songs) {
song.link(this) 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) { if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded earliestDateAdded = song.dateAdded
} }
totalDuration += song.durationMs totalDuration += song.durationMs
} }
val min = minDate
val max = maxDate
dates = if (min != null && max != null) Date.Range(min, max) else null
durationMs = totalDuration durationMs = totalDuration
dateAdded = earliestDateAdded 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 * 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 * 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<Song> override val songs: List<Song>
override val albums: List<Album> override val albums: List<Album>
override val explicitAlbums: List<Album>
override val implicitAlbums: List<Album>
override val durationMs: Long? override val durationMs: Long?
override val isCollaborator: Boolean
override lateinit var genres: List<Genre>
private var hashCode = uid.hashCode()
init {
val distinctSongs = mutableSetOf<Song>()
val albumMap = mutableMapOf<Album, Boolean>()
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 // Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal. // the same UID but different songs are not equal.
override fun hashCode(): Int { override fun hashCode() = hashCode
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun equals(other: Any?) = override fun equals(other: Any?) =
other is ArtistImpl && other is ArtistImpl &&
@ -354,35 +413,7 @@ class ArtistImpl(
rawArtist == other.rawArtist && rawArtist == other.rawArtist &&
songs == other.songs songs == other.songs
override lateinit var genres: List<Genre> override fun toString() = "Artist(uid=$uid, name=$name)"
init {
val distinctSongs = mutableSetOf<Song>()
val distinctAlbums = mutableSetOf<Album>()
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
}
/** /**
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist] * 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. * [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list. * @return The index of the [Artist]'s [RawArtist] within the list.
*/ */
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist) fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
rawArtists.indexOfFirst { it.key == rawArtist.key }
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.
@ -427,19 +459,10 @@ class GenreImpl(
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) } rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
?: Name.Unknown(R.string.def_genre) ?: Name.Unknown(R.string.def_genre)
override val albums: List<Album>
override val artists: List<Artist> override val artists: List<Artist>
override val durationMs: Long override val durationMs: Long
override fun hashCode(): Int { private var hashCode = uid.hashCode()
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
init { init {
val distinctAlbums = mutableSetOf<Album>() val distinctAlbums = mutableSetOf<Album>()
@ -453,14 +476,19 @@ class GenreImpl(
totalDuration += song.durationMs 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) artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists)
durationMs = totalDuration 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. * 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 * 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. * [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list. * @return The index of the [Genre]'s [RawGenre] within the list.
*/ */
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre) fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
rawGenres.indexOfFirst { it.key == rawGenre.key }
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.

View file

@ -19,7 +19,9 @@
package org.oxycblt.auxio.music.device package org.oxycblt.auxio.music.device
import java.util.UUID 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.fs.Directory
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.info.ReleaseType
@ -111,28 +113,35 @@ data class RawAlbum(
/** @see RawArtist.name */ /** @see RawArtist.name */
val rawArtists: List<RawArtist> val rawArtists: List<RawArtist>
) { ) {
// Albums are grouped as follows: val key = Key(this)
// - 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").
// Cache the hash-code for HashMap efficiency. /** Exposed information that denotes [RawAlbum] uniqueness. */
private val hashCode = data class Key(val value: RawAlbum) {
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode()) // 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?) = override fun hashCode() = hashCode
other is RawAlbum &&
when { override fun equals(other: Any?) =
musicBrainzId != null && other.musicBrainzId != null -> other is Key &&
musicBrainzId == other.musicBrainzId when {
musicBrainzId == null && other.musicBrainzId == null -> value.musicBrainzId != null && other.value.musicBrainzId != null ->
name.equals(other.name, true) && rawArtists == other.rawArtists value.musicBrainzId == other.value.musicBrainzId
else -> false 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 */ /** @see Music.name */
val sortName: String? = null val sortName: String? = null
) { ) {
// Artists are grouped as follows: val key = Key(this)
// - 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.
// 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 // Cache the hashCode for HashMap efficiency.
// same name in large libraries. 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?) = override fun hashCode() = hashCode
other is RawArtist &&
when { override fun equals(other: Any?) =
musicBrainzId != null && other.musicBrainzId != null -> other is Key &&
musicBrainzId == other.musicBrainzId when {
musicBrainzId == null && other.musicBrainzId == null -> value.musicBrainzId != null && other.value.musicBrainzId != null ->
when { value.musicBrainzId == other.value.musicBrainzId
name != null && other.name != null -> name.equals(other.name, true) value.musicBrainzId == null && other.value.musicBrainzId == null ->
name == null && other.name == null -> true when {
else -> false value.name != null && other.value.name != null ->
} value.name.equals(other.value.name, true)
else -> false value.name == null && other.value.name == null -> true
} else -> false
}
else -> false
}
}
} }
/** /**
@ -187,20 +205,24 @@ data class RawGenre(
/** @see Music.name */ /** @see Music.name */
val name: String? = null val name: String? = null
) { ) {
val key = Key(this)
// Cache the hashCode for HashMap efficiency. data class Key(val value: RawGenre) {
private val hashCode = name?.lowercase().hashCode() // 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 // 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 // case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres. // formatting genres.
override fun hashCode() = hashCode override fun hashCode() = hashCode
override fun equals(other: Any?) = override fun equals(other: Any?) =
other is RawGenre && other is Key &&
when { when {
name != null && other.name != null -> name.equals(other.name, true) value.name != null && other.value.name != null ->
name == null && other.name == null -> true value.name.equals(other.value.name, true)
else -> false value.name == null && other.value.name == null -> true
} else -> false
}
}
} }

View file

@ -25,6 +25,7 @@ import org.oxycblt.auxio.databinding.ItemMusicDirBinding
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/** /**
* [RecyclerView.Adapter] that manages a list of [Directory] instances. * [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. * @param dir The [Directory] to add.
*/ */
fun add(dir: Directory) { fun add(dir: Directory) {
if (_dirs.contains(dir)) { if (_dirs.contains(dir)) return
return logD("Adding $dir")
}
_dirs.add(dir) _dirs.add(dir)
notifyItemInserted(_dirs.lastIndex) 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. * 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<Directory>) { fun addAll(dirs: List<Directory>) {
logD("Adding ${dirs.size} directories")
val oldLastIndex = dirs.lastIndex val oldLastIndex = dirs.lastIndex
_dirs.addAll(dirs) _dirs.addAll(dirs)
notifyItemRangeInserted(oldLastIndex, dirs.size) 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. * @param dir The [Directory] to remove. Must exist in the list.
*/ */
fun remove(dir: Directory) { fun remove(dir: Directory) {
logD("Removing $dir")
val idx = _dirs.indexOf(dir) val idx = _dirs.indexOf(dir)
_dirs.removeAt(idx) _dirs.removeAt(idx)
notifyItemRemoved(idx) notifyItemRemoved(idx)
@ -86,6 +87,7 @@ class DirectoryAdapter(private val listener: Listener) :
/** A Listener for [DirectoryAdapter] interactions. */ /** A Listener for [DirectoryAdapter] interactions. */
interface Listener { interface Listener {
/** Called when the delete button on a directory item is clicked. */
fun onRemoveDirectory(dir: Directory) fun onRemoveDirectory(dir: Directory)
} }
} }

View file

@ -145,6 +145,8 @@ data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolea
* @param fromFormat The mime type obtained by analyzing the file format. Null if could not be * @param fromFormat The mime type obtained by analyzing the file format. Null if could not be
* obtained. * obtained.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Get around to simplifying this
*/ */
data class MimeType(val fromExtension: String, val fromFormat: String?) { data class MimeType(val fromExtension: String, val fromFormat: String?) {
/** /**

View file

@ -120,6 +120,7 @@ private abstract class BaseMediaStoreExtractor(
if (dirs.dirs.isNotEmpty()) { if (dirs.dirs.isNotEmpty()) {
selector += " AND " selector += " AND "
if (!dirs.shouldInclude) { if (!dirs.shouldInclude) {
logD("Excluding directories in selector")
// Without a NOT, the query will be restricted to the specified paths, resulting // 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, // in the "Include" mode. With a NOT, the specified paths will not be included,
// resulting in the "Exclude" mode. // resulting in the "Exclude" mode.
@ -144,14 +145,14 @@ private abstract class BaseMediaStoreExtractor(
} }
// Now we can actually query MediaStore. // 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 = val cursor =
context.contentResolverSafe.safeQuery( context.contentResolverSafe.safeQuery(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection, projection,
selector, selector,
args.toTypedArray()) args.toTypedArray())
logD("Song query succeeded [Projected total: ${cursor.count}]") logD("Successfully queried for ${cursor.count} songs")
val genreNamesMap = mutableMapOf<Long, String>() val genreNamesMap = mutableMapOf<Long, String>()
@ -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") logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
return wrapQuery(cursor, genreNamesMap) return wrapQuery(cursor, genreNamesMap)
} }

View file

@ -24,6 +24,7 @@ import java.text.SimpleDateFormat
import kotlin.math.max import kotlin.math.max
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.inRangeOrNull import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
/** /**
@ -51,33 +52,30 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
* be properly localized. * be properly localized.
*/ */
fun resolveDate(context: Context): String { fun resolve(context: Context) =
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)
}
}
// Unable to create fine-grained date, just format as a year. // 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 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 { override fun compareTo(other: Date): Int {
for (i in 0 until max(tokens.size, other.tokens.size)) { for (i in 0 until max(tokens.size, other.tokens.size)) {
val ai = tokens.getOrNull(i) val ai = tokens.getOrNull(i)
@ -98,8 +96,6 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
return 0 return 0
} }
override fun toString() = StringBuilder().appendDate().toString()
private fun StringBuilder.appendDate(): StringBuilder { private fun StringBuilder.appendDate(): StringBuilder {
// Construct an ISO-8601 date, dropping precision that doesn't exist. // Construct an ISO-8601 date, dropping precision that doesn't exist.
append(year.toStringFixed(4)) append(year.toStringFixed(4))
@ -120,13 +116,15 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
* *
* @author Alexander Capehart * @author Alexander Capehart
*/ */
class Range class Range(
private constructor(
/** The earliest [Date] in the range. */ /** The earliest [Date] in the range. */
val min: Date, val min: Date,
/** the latest [Date] in the range. May be the same as [min]. ] */ /** the latest [Date] in the range. May be the same as [min]. ] */
val max: Date val max: Date
) : Comparable<Range> { ) : Comparable<Range> {
init {
check(min <= max) { "Min date must be <= max date" }
}
/** /**
* Resolve this instance into a human-readable date range. * Resolve this instance into a human-readable date range.
@ -139,9 +137,9 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
fun resolveDate(context: Context) = fun resolveDate(context: Context) =
if (min != max) { if (min != max) {
context.getString( 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 { } else {
min.resolveDate(context) min.resolve(context)
} }
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max 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<Int>) : Comparable<Date>
override fun hashCode() = 31 * max.hashCode() + min.hashCode() override fun hashCode() = 31 * max.hashCode() + min.hashCode()
override fun compareTo(other: Range) = min.compareTo(other.min) 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<Date>): 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 { companion object {

View file

@ -27,6 +27,7 @@ import org.oxycblt.auxio.list.Item
* @param name The name of the disc group, if any. Null if not present. * @param name The name of the disc group, if any. Null if not present.
*/ */
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> { class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
// 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 equals(other: Any?) = other is Disc && number == other.number
override fun hashCode() = number.hashCode() override fun hashCode() = number.hashCode()
override fun compareTo(other: Disc) = number.compareTo(other.number) override fun compareTo(other: Disc) = number.compareTo(other.number)

View file

@ -174,6 +174,8 @@ private data class IntelligentKnownName(override val raw: String, override val s
override val sortTokens = parseTokens(sort ?: raw) override val sortTokens = parseTokens(sort ?: raw)
private fun parseTokens(name: String): List<SortToken> { private fun parseTokens(name: String): List<SortToken> {
// TODO: This routine is consuming much of the song building runtime, find a way to
// optimize it
val stripped = val stripped =
name name
// Remove excess punctuation from the string, as those u // 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. // Separate each token into their numeric and lexicographic counterparts.
if (token.first().isDigit()) { if (token.first().isDigit()) {
// The digit string comparison breaks with preceding zero digits, remove those // 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 } val digits = token.trimStart('0').ifEmpty { token }
// Other languages have other types of digit strings, still use collation keys // Other languages have other types of digit strings, still use collation keys
collationKey = COLLATOR.getCollationKey(digits) collationKey = COLLATOR.getCollationKey(digits)

View file

@ -19,6 +19,7 @@
package org.oxycblt.auxio.music.info package org.oxycblt.auxio.music.info
import org.oxycblt.auxio.R 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. * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc.

View file

@ -104,25 +104,23 @@ constructor(@ApplicationContext private val context: Context) : AudioProperties.
null null
} }
val resolvedMimeType = // The song's mime type won't have a populated format field right now, try to
if (song.mimeType.fromFormat != null) { // extract it ourselves.
// ExoPlayer was already able to populate the format. val formatMimeType =
song.mimeType try {
} else { format.getString(MediaFormat.KEY_MIME)
// ExoPlayer couldn't populate the format somehow, populate it here. } catch (e: NullPointerException) {
val formatMimeType = logE("Unable to extract mime type field")
try { null
format.getString(MediaFormat.KEY_MIME)
} catch (e: NullPointerException) {
logE("Unable to extract mime type field")
null
}
MimeType(song.mimeType.fromExtension, formatMimeType)
} }
extractor.release() extractor.release()
return AudioProperties(bitrate, sampleRate, resolvedMimeType) logD("Finished extracting audio properties")
return AudioProperties(
bitrate,
sampleRate,
MimeType(fromExtension = song.mimeType.fromExtension, fromFormat = formatMimeType))
} }
} }

View file

@ -30,12 +30,15 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment 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 * A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
* split tags with multiple values. * split tags with multiple values.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Replace with unsplit names dialog
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() { class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
@ -74,7 +77,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
Separators.SLASH -> binding.separatorSlash.isChecked = true Separators.SLASH -> binding.separatorSlash.isChecked = true
Separators.PLUS -> binding.separatorPlus.isChecked = true Separators.PLUS -> binding.separatorPlus.isChecked = true
Separators.AND -> binding.separatorAnd.isChecked = true Separators.AND -> binding.separatorAnd.isChecked = true
else -> error("Unexpected separator in settings data") else -> logW("Unexpected separator in settings data")
} }
} }
} }

View file

@ -23,6 +23,7 @@ import javax.inject.Inject
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.device.RawSong 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 * 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. // producing similar throughput's to other kinds of manual metadata extraction.
val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY) val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
logD("Beginning primary extraction loop")
for (incompleteRawSong in incompleteSongs) { for (incompleteRawSong in incompleteSongs) {
spin@ while (true) { spin@ while (true) {
for (i in tagWorkerPool.indices) { 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 { do {
var ongoingTasks = false var ongoingTasks = false
for (i in tagWorkerPool.indices) { for (i in tagWorkerPool.indices) {

View file

@ -39,6 +39,8 @@ fun List<String>.parseMultiValue(settings: MusicSettings) =
this 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 * Split a [String] by the given selector, automatically handling escaped characters that satisfy
* the selector. * the selector.
@ -106,7 +108,7 @@ fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
* @return A list of one or more [String]s that were split up by the user-defined separators. * @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<String> { private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
// 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() return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace()
} }

View file

@ -89,12 +89,8 @@ private class TagWorkerImpl(
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract metadata for ${rawSong.name}") logW("Unable to extract metadata for ${rawSong.name}")
logW(e.stackTraceToString()) logW(e.stackTraceToString())
null return rawSong
} }
if (format == null) {
logD("Nothing could be extracted for ${rawSong.name}")
return rawSong
}
val metadata = format.metadata val metadata = format.metadata
if (metadata != null) { if (metadata != null) {

View file

@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -93,7 +94,7 @@ class AddToPlaylistDialog :
private fun updatePendingSongs(songs: List<Song>?) { private fun updatePendingSongs(songs: List<Song>?) {
if (songs == null) { if (songs == null) {
// No songs to feasibly add to a playlist, leave. logD("No songs to show choices for, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
} }
} }

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -76,7 +77,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBindi
private fun updatePlaylistToDelete(playlist: Playlist?) { private fun updatePlaylistToDelete(playlist: Playlist?) {
if (playlist == null) { if (playlist == null) {
// Playlist does not exist anymore, leave logD("No playlist to delete, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }

View file

@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -89,6 +90,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
if (pendingPlaylist == null) { if (pendingPlaylist == null) {
logD("No playlist to create, leaving")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }

View file

@ -31,6 +31,9 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song 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. * 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.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
} }
logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}")
_currentSongsToAdd.value = _currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs -> _currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs pendingSongs
@ -91,6 +96,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
.ifEmpty { null } .ifEmpty { null }
.also { refreshChoicesWith = it } .also { refreshChoicesWith = it }
} }
logD("Updated songs to add: ${_currentSongsToAdd.value?.size} songs")
} }
val chosenName = _chosenName.value val chosenName = _chosenName.value
@ -102,6 +108,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
// Nothing to do. // Nothing to do.
} }
} }
logD("Updated chosen name to $chosenName")
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value 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. * @param songUids The [Music.UID]s of songs to be present in the playlist.
*/ */
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) { fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return logD("Opening ${songUids.size} songs to create a playlist from")
val songs = songUids.mapNotNull(deviceLibrary::findSong)
val userLibrary = musicRepository.userLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return
var i = 1 val songs =
while (true) { musicRepository.deviceLibrary
val possibleName = context.getString(R.string.fmt_def_playlist, i) ?.let { songUids.mapNotNull(it::findSong) }
if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) { ?.also(::refreshPlaylistChoices)
_currentPendingPlaylist.value = PendingPlaylist(possibleName, songs)
return 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. * @param playlistUid The [Music.UID]s of the [Playlist] to rename.
*/ */
fun setPlaylistToRename(playlistUid: Music.UID) { fun setPlaylistToRename(playlistUid: Music.UID) {
logD("Opening playlist $playlistUid to rename")
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) _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. * @param playlistUid The [Music.UID] of the [Playlist] to delete.
*/ */
fun setPlaylistToDelete(playlistUid: Music.UID) { fun setPlaylistToDelete(playlistUid: Music.UID) {
logD("Opening playlist $playlistUid to delete")
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid) _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. * @param name The new user-inputted name, or null if not present.
*/ */
fun updateChosenName(name: String?) { fun updateChosenName(name: String?) {
logD("Updating chosen name to $name")
_chosenName.value = _chosenName.value =
when { when {
name.isNullOrEmpty() -> ChosenName.Empty name.isNullOrEmpty() -> {
name.isBlank() -> ChosenName.Blank logE("Chosen name is empty")
ChosenName.Empty
}
name.isBlank() -> {
logE("Chosen name is blank")
ChosenName.Blank
}
else -> { else -> {
val trimmed = name.trim() val trimmed = name.trim()
val userLibrary = musicRepository.userLibrary val userLibrary = musicRepository.userLibrary
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) { if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
logD("Chosen name is valid")
ChosenName.Valid(trimmed) ChosenName.Valid(trimmed)
} else { } else {
logD("Chosen name already exists in library")
ChosenName.AlreadyExists(trimmed) 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. * @param songUids The [Music.UID]s of songs to add to a playlist.
*/ */
fun setSongsToAdd(songUids: Array<Music.UID>) { fun setSongsToAdd(songUids: Array<Music.UID>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return logD("Opening ${songUids.size} songs to add to a playlist")
val songs = songUids.mapNotNull(deviceLibrary::findSong) _currentSongsToAdd.value =
_currentSongsToAdd.value = songs musicRepository.deviceLibrary
refreshPlaylistChoices(songs) ?.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<Song>) { private fun refreshPlaylistChoices(songs: List<Song>) {
val userLibrary = musicRepository.userLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return
logD("Refreshing playlist choices")
_playlistAddChoices.value = _playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
val songSet = it.songs.toSet() val songSet = it.songs.toSet()

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -86,7 +87,9 @@ class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding
} }
if (!initializedField) { if (!initializedField) {
requireBinding().playlistName.setText(playlist.name.resolve(requireContext())) val default = playlist.name.resolve(requireContext())
logD("Name input is not initialized, setting to $default")
requireBinding().playlistName.setText(default)
initializedField = true initializedField = true
} }
} }

View file

@ -28,12 +28,17 @@ import android.os.PowerManager
import android.provider.MediaStore import android.provider.MediaStore
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.Runnable
import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.service.ForegroundManager
@ -119,6 +124,7 @@ class IndexerService :
// --- CONTROLLER CALLBACKS --- // --- CONTROLLER CALLBACKS ---
override fun requestIndex(withCache: Boolean) { override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job")
// Cancel the previous music loading job. // Cancel the previous music loading job.
currentIndexJob?.cancel() currentIndexJob?.cancel()
// Start a new music loading job on a co-routine. // Start a new music loading job on a co-routine.
@ -132,6 +138,7 @@ class IndexerService :
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers // Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear() imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected // Clear invalid models from PlaybackStateManager. This is not connected
@ -187,11 +194,14 @@ class IndexerService :
// and thus the music library will not be updated at all. // and thus the music library will not be updated at all.
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
// this anymore, or at least I only have to use it when the app task is not removed. // this anymore, or at least I only have to use it when the app task is not removed.
logD("Need to observe, staying in foreground")
if (!foregroundManager.tryStartForeground(observingNotification)) { if (!foregroundManager.tryStartForeground(observingNotification)) {
logD("Notification changed, re-posting notification")
observingNotification.post() observingNotification.post()
} }
} else { } else {
// Not observing and done loading, exit foreground. // Not observing and done loading, exit foreground.
logD("Exiting foreground")
foregroundManager.tryStopForeground() foregroundManager.tryStopForeground()
} }
// Release our wake lock (if we were using it) // Release our wake lock (if we were using it)
@ -232,6 +242,7 @@ class IndexerService :
// setting changed. In such a case, the state will still be updated when // setting changed. In such a case, the state will still be updated when
// the music loading process ends. // the music loading process ends.
if (currentIndexJob == null) { if (currentIndexJob == null) {
logD("Not loading, updating idle session")
updateIdleSession() updateIdleSession()
} }
} }
@ -269,6 +280,7 @@ class IndexerService :
// Check here if we should even start a reindex. This is much less bug-prone than // Check here if we should even start a reindex. This is much less bug-prone than
// registering and de-registering this component as this setting changes. // registering and de-registering this component as this setting changes.
if (musicSettings.shouldBeObserving) { if (musicSettings.shouldBeObserving) {
logD("MediaStore changed, starting re-index")
requestIndex(true) requestIndex(true)
} }
} }

View file

@ -18,7 +18,11 @@
package org.oxycblt.auxio.music.user package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
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.music.device.DeviceLibrary
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
@ -29,8 +33,17 @@ private constructor(
override val songs: List<Song> override val songs: List<Song>
) : Playlist { ) : Playlist {
override val durationMs = songs.sumOf { it.durationMs } override val durationMs = songs.sumOf { it.durationMs }
override val albums = private var hashCode = uid.hashCode()
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
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]. * 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<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits)) inline fun edit(edits: MutableList<Song>.() -> 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 { companion object {
/** /**
* Create a new instance with a novel UID. * Create a new instance with a novel UID.

View file

@ -18,7 +18,12 @@
package org.oxycblt.auxio.music.user 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 import org.oxycblt.auxio.music.Music
/** /**

View file

@ -18,10 +18,17 @@
package org.oxycblt.auxio.music.user package org.oxycblt.auxio.music.user
import java.lang.Exception
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.channels.Channel 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.music.device.DeviceLibrary
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/** /**
* Organized library information controlled by the user. * Organized library information controlled by the user.
@ -118,7 +125,14 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus
UserLibrary.Factory { UserLibrary.Factory {
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary { override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
// While were waiting for the library, read our playlists out. // 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() val deviceLibrary = deviceLibraryChannel.receive()
// Convert the database playlist information to actual usable playlists. // Convert the database playlist information to actual usable playlists.
val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>() val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
@ -135,6 +149,10 @@ private class UserLibraryImpl(
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>, private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
private val musicSettings: MusicSettings private val musicSettings: MusicSettings
) : MutableUserLibrary { ) : 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<Playlist> override val playlists: List<Playlist>
get() = playlistMap.values.toList() get() = playlistMap.values.toList()
@ -143,40 +161,81 @@ private class UserLibraryImpl(
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
override suspend fun createPlaylist(name: String, songs: List<Song>) { override suspend fun createPlaylist(name: String, songs: List<Song>) {
// TODO: Use synchronized with value access too
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
val rawPlaylist = val rawPlaylist =
RawPlaylist( RawPlaylist(
PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw), PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw),
playlistImpl.songs.map { PlaylistSong(it.uid) }) 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) { override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val playlistImpl = val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) } 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) { override suspend fun deletePlaylist(playlist: Playlist) {
synchronized(this) { val playlistImpl =
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } 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<Song>) { override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl = val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } 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<Song>) { override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl = val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" }
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) } 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
}
} }
} }

View file

@ -18,7 +18,14 @@
package org.oxycblt.auxio.music.user 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 import org.oxycblt.auxio.music.Music
/** /**

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.logD
* *
* @author Alexander Capehart (OxygenCobalt) * @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() { class NavigationViewModel : ViewModel() {
private val _mainNavigationAction = MutableEvent<MainNavigationAction>() private val _mainNavigationAction = MutableEvent<MainNavigationAction>()
@ -96,6 +96,7 @@ class NavigationViewModel : ViewModel() {
* dialog will be shown. * dialog will be shown.
*/ */
fun exploreNavigateToParentArtist(song: Song) { fun exploreNavigateToParentArtist(song: Song) {
logD("Navigating to parent artist of $song")
exploreNavigateToParentArtistImpl(song, song.artists) exploreNavigateToParentArtistImpl(song, song.artists)
} }
@ -106,6 +107,7 @@ class NavigationViewModel : ViewModel() {
* dialog will be shown. * dialog will be shown.
*/ */
fun exploreNavigateToParentArtist(album: Album) { fun exploreNavigateToParentArtist(album: Album) {
logD("Navigating to parent artist of $album")
exploreNavigateToParentArtistImpl(album, album.artists) exploreNavigateToParentArtistImpl(album, album.artists)
} }

View file

@ -78,7 +78,7 @@ class NavigateToArtistDialog :
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
choiceAdapter binding.choiceRecycler.adapter = null
} }
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {

View file

@ -23,7 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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 * 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 else -> null
} }
logD("Updated artist choices: ${_artistChoices.value}")
} }
override fun onCleared() { 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]. * @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
*/ */
fun setArtistChoiceUid(itemUid: Music.UID) { fun setArtistChoiceUid(itemUid: Music.UID) {
logD("Opening navigation choices for $itemUid")
// Support Songs and Albums, which have parent artists. // Support Songs and Albums, which have parent artists.
_artistChoices.value = _artistChoices.value =
when (val music = musicRepository.find(itemUid)) { when (val music = musicRepository.find(itemUid)) {
is Song -> SongArtistNavigationChoices(music) is Song -> {
is Album -> AlbumArtistNavigationChoices(music) logD("Creating navigation choices for song")
else -> null SongArtistNavigationChoices(music)
}
is Album -> {
logD("Creating navigation choices for album")
AlbumArtistNavigationChoices(music)
}
else -> {
logD("Given song/album UID was invalid")
null
}
} }
} }
} }

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.R as MR
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding 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.collectImmediately
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.logD
/** /**
* A [ViewBindingFragment] that shows the current playback state in a compact manner. * A [ViewBindingFragment] that shows the current playback state in a compact manner.
@ -92,14 +94,16 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) { private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) {
when (actionMode) { when (actionMode) {
ActionMode.NEXT -> { ActionMode.NEXT -> {
logD("Setting up skip next action")
binding.playbackSecondaryAction.apply { binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.ic_skip_next_24) setIconResource(R.drawable.ic_skip_next_24)
contentDescription = getString(R.string.desc_skip_next) contentDescription = getString(R.string.desc_skip_next)
iconTint = context.getAttrColorCompat(R.attr.colorOnSurfaceVariant) iconTint = context.getAttrColorCompat(MR.attr.colorOnSurfaceVariant)
setOnClickListener { playbackModel.next() } setOnClickListener { playbackModel.next() }
} }
} }
ActionMode.REPEAT -> { ActionMode.REPEAT -> {
logD("Setting up repeat mode action")
binding.playbackSecondaryAction.apply { binding.playbackSecondaryAction.apply {
contentDescription = getString(R.string.desc_change_repeat) contentDescription = getString(R.string.desc_change_repeat)
iconTint = context.getColorCompat(R.color.sel_activatable_icon) iconTint = context.getColorCompat(R.color.sel_activatable_icon)
@ -108,6 +112,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
} }
} }
ActionMode.SHUFFLE -> { ActionMode.SHUFFLE -> {
logD("Setting up shuffle action")
binding.playbackSecondaryAction.apply { binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.sel_shuffle_state_24) setIconResource(R.drawable.sel_shuffle_state_24)
contentDescription = getString(R.string.desc_shuffle) contentDescription = getString(R.string.desc_shuffle)
@ -120,14 +125,17 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
} }
private fun updateSong(song: Song?) { private fun updateSong(song: Song?) {
if (song != null) { if (song == null) {
val context = requireContext() // Nothing to do.
val binding = requireBinding() return
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()
} }
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) { private fun updatePlaying(isPlaying: Boolean) {

View file

@ -24,6 +24,7 @@ import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
@ -39,7 +40,7 @@ class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: Attr
BaseBottomSheetBehavior<V>(context, attributeSet) { BaseBottomSheetBehavior<V>(context, attributeSet) {
val sheetBackgroundDrawable = val sheetBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply { MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface) fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal) elevation = context.getDimen(R.dimen.elevation_normal)
} }

View file

@ -43,6 +43,8 @@ import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collectImmediately 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.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -141,6 +143,7 @@ class PlaybackPanelFragment :
when (item.itemId) { when (item.itemId) {
R.id.action_open_equalizer -> { R.id.action_open_equalizer -> {
// Launch the system equalizer app, if possible. // Launch the system equalizer app, if possible.
logD("Launching equalizer")
val equalizerIntent = val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
// Provide audio session ID so the equalizer can show options for this app // Provide audio session ID so the equalizer can show options for this app
@ -180,6 +183,10 @@ class PlaybackPanelFragment :
} }
true true
} }
R.id.action_share -> {
playbackModel.song.value?.let { requireContext().share(it) }
true
}
else -> false else -> false
} }
@ -195,6 +202,7 @@ class PlaybackPanelFragment :
val binding = requireBinding() val binding = requireBinding()
val context = requireContext() val context = requireContext()
logD("Updating song display: $song")
binding.playbackCover.bind(song) binding.playbackCover.bind(song)
binding.playbackSong.text = song.name.resolve(context) binding.playbackSong.text = song.name.resolve(context)
binding.playbackArtist.text = song.artists.resolveNames(context) binding.playbackArtist.text = song.artists.resolveNames(context)
@ -228,13 +236,11 @@ class PlaybackPanelFragment :
requireBinding().playbackShuffle.isActivated = isShuffled requireBinding().playbackShuffle.isActivated = isShuffled
} }
/** Navigate to one of the currently playing [Song]'s Artists. */
private fun navigateToCurrentArtist() { private fun navigateToCurrentArtist() {
val song = playbackModel.song.value ?: return val song = playbackModel.song.value ?: return
navModel.exploreNavigateToParentArtist(song) navModel.exploreNavigateToParentArtist(song)
} }
/** Navigate to the currently playing [Song]'s albums. */
private fun navigateToCurrentAlbum() { private fun navigateToCurrentAlbum() {
val song = playbackModel.song.value ?: return val song = playbackModel.song.value ?: return
navModel.exploreNavigateTo(song.album) navModel.exploreNavigateTo(song.album)

View file

@ -198,8 +198,14 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont
when (key) { when (key) {
getString(R.string.set_key_replay_gain), getString(R.string.set_key_replay_gain),
getString(R.string.set_key_pre_amp_with), getString(R.string.set_key_pre_amp_with),
getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged() getString(R.string.set_key_pre_amp_without) -> {
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged() logD("Dispatching ReplayGain setting change")
listener.onReplayGainSettingsChanged()
}
getString(R.string.set_key_notif_action) -> {
logD("Dispatching notification setting change")
listener.onNotificationActionChanged()
}
} }
} }

Some files were not shown because too many files have changed in this diff Show more