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
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test app with Gradle
run: ./gradlew app:testDebug
# - name: Test app with Gradle
# run: ./gradlew app:testDebug
- name: Build debug APK with Gradle
run: ./gradlew app:packageDebug
- name: Upload debug APK artifact

View file

@ -1,5 +1,32 @@
# Changelog
## 3.1.1
#### What's New
- Added ability to share a track
#### What's Improved
- Tracks with no disc number now default to "No Disc" instead of "Disc 1"
- Albums implicitly linked only via "artist" tags are now placed in a special
"appears on" section in the artist view
- Album covers that are not 1:1 aspect ratio are no longer cropped
- Optimized library creation phase of the music loading process
#### What's Fixed
- Prevented options such as "Add to queue" from being selected on empty artists and playlists
- Fixed issue where an item would be indicated as "playing" after playback ended
- Items should no longer be indicated as playing if the currently playing song is not contained
within it
- Fixed blurry playing indicator in album/artist/genre/playlist items
- Fixed incorrect songs being displayed when adding albums to the end of the queue
- Fixed freezing occuring when scrolling through large music libraries
- Fixed app not responding once music loading completes for large libraries
- Fixed crash when the last song of the queue gets removed while playing
- Fixed playback UI and notification not re-appearing after playback ends
#### What's Changed
- Android Lollipop and Marshmallow support have been dropped
## 3.1.0
#### What's New

View file

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

View file

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

View file

@ -16,8 +16,6 @@
package com.google.android.material.bottomsheet;
import com.google.android.material.R;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static java.lang.Math.max;
import static java.lang.Math.min;
@ -44,6 +42,7 @@ import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.FloatRange;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
@ -63,11 +62,14 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.Accessibilit
import androidx.core.view.accessibility.AccessibilityViewCommand;
import androidx.customview.view.AbsSavedState;
import androidx.customview.widget.ViewDragHelper;
import com.google.android.material.R;
import com.google.android.material.internal.ViewUtils;
import com.google.android.material.internal.ViewUtils.RelativePadding;
import com.google.android.material.resources.MaterialResources;
import com.google.android.material.shape.MaterialShapeDrawable;
import com.google.android.material.shape.ShapeAppearanceModel;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
@ -1334,6 +1336,19 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return state;
}
/**
* Gets the target state of the bottom sheet if currently attempting to settle, or the current
* state otherwise.
* @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
* or {@link #STATE_DRAGGING}
*/
public int getTargetState() {
if (state != STATE_SETTLING) {
return state;
}
return stateSettlingTracker.targetState;
}
void setStateInternal(@State int state) {
if (this.state == state) {
return;

View file

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

View file

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

View file

@ -26,11 +26,13 @@ import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.R as MR
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.transition.MaterialFadeThrough
@ -50,13 +52,24 @@ import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
* high-level navigation features.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Break up the god navigation setup going on here
*/
@AndroidEntryPoint
class MainFragment :
@ -68,7 +81,10 @@ class MainFragment :
private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback()
private var sheetBackCallback: SheetBackPressedCallback? = null
private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null
private var exploreBackCallback: ExploreBackPressedCallback? = null
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
private var initialNavDestinationChange = true
@ -84,13 +100,38 @@ class MainFragment :
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
// that instantiating these callbacks in their respective fragments would result in the
// correct order.
val sheetBackCallback =
SheetBackPressedCallback(
playbackSheetBehavior = playbackSheetBehavior,
queueSheetBehavior = queueSheetBehavior)
.also { sheetBackCallback = it }
val detailBackCallback =
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
val selectionBackCallback =
SelectionBackPressedCallback(selectionModel).also { selectionBackCallback = it }
val exploreBackCallback =
ExploreBackPressedCallback(binding.exploreNavHost).also { exploreBackCallback = it }
// --- UI SETUP ---
val context = requireActivity()
// Override the back pressed listener so we can map back navigation to collapsing
// navigation, navigation out of detail views, etc.
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
context.onBackPressedDispatcher.apply {
addCallback(viewLifecycleOwner, exploreBackCallback)
addCallback(viewLifecycleOwner, selectionBackCallback)
addCallback(viewLifecycleOwner, detailBackCallback)
addCallback(viewLifecycleOwner, sheetBackCallback)
}
binding.root.setOnApplyWindowInsetsListener { _, insets ->
lastInsets = insets
@ -103,13 +144,10 @@ class MainFragment :
ViewCompat.setAccessibilityPaneTitle(
binding.queueSheet, context.getString(R.string.lbl_queue))
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
if (queueSheetBehavior != null) {
// Bottom sheet mode, set up click listeners.
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
// In portrait mode, set up click listeners on the stacked sheets.
logD("Configuring stacked bottom sheets")
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
@ -118,14 +156,15 @@ class MainFragment :
}
} else {
// Dual-pane mode, manually style the static queue sheet.
logD("Configuring dual-pane bottom sheet")
binding.queueSheet.apply {
// Emulate the elevated bottom sheet style.
background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal)
}
// Apply bar insets for the queue's RecyclerView to usee.
// Apply bar insets for the queue's RecyclerView to use.
setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(top = insets.systemBarInsetsCompat.top)
insets
@ -134,13 +173,15 @@ class MainFragment :
}
// --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -165,6 +206,14 @@ class MainFragment :
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
}
override fun onDestroyBinding(binding: FragmentMainBinding) {
super.onDestroyBinding(binding)
sheetBackCallback = null
detailBackCallback = null
selectionBackCallback = null
exploreBackCallback = null
}
override fun onPreDraw(): Boolean {
// We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should
@ -250,7 +299,8 @@ class MainFragment :
// Since the navigation listener is also reliant on the bottom sheets, we must also update
// it every frame.
callback.invalidateEnabled()
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled()
return true
}
@ -263,6 +313,8 @@ class MainFragment :
// Drop the initial call by NavController that simply provides us with the current
// destination. This would cause the selection state to be lost every time the device
// rotates.
requireNotNull(exploreBackCallback) { "ExploreBackPressedCallback was not available" }
.invalidateEnabled()
if (!initialNavDestinationChange) {
initialNavDestinationChange = true
return
@ -271,19 +323,15 @@ class MainFragment :
}
private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) {
// Nothing to do.
return
if (action != null) {
when (action) {
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
is MainNavigationAction.Directions ->
findNavController().navigateSafe(action.directions)
}
navModel.mainNavigationAction.consume()
}
when (action) {
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
is MainNavigationAction.Directions ->
findNavController().navigateSafe(action.directions)
}
navModel.mainNavigationAction.consume()
}
private fun handleExploreNavigation(item: Music?) {
@ -368,6 +416,7 @@ class MainFragment :
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it.
logD("Expanding playback sheet")
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
return
}
@ -378,6 +427,7 @@ class MainFragment :
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Queue sheet and playback sheet is expanded, close the queue sheet so the
// playback panel can eb shown.
logD("Collapsing queue sheet")
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
@ -388,6 +438,7 @@ class MainFragment :
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Playback sheet (and possibly queue) needs to be collapsed.
logD("Collapsing playback and queue sheets")
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -399,7 +450,8 @@ class MainFragment :
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) {
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
logD("Unhiding and enabling playback sheet")
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed
@ -416,10 +468,12 @@ class MainFragment :
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
if (playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
logD("Hiding and disabling playback and queue sheets")
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply {
isDraggable = false
@ -433,71 +487,86 @@ class MainFragment :
}
}
/**
* A [OnBackPressedCallback] that overrides the back button to first navigate out of internal
* app components, such as the Bottom Sheets or Explore Navigation.
*/
private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// TODO: Use targetState more
private class SheetBackPressedCallback(
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
) : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
// If expanded, collapse the queue sheet first.
if (queueSheetBehavior != null &&
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).state =
BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed queue sheet")
return
}
// If expanded, collapse the playback sheet next.
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
if (playbackSheetShown()) {
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed playback sheet")
return
}
// Clear out pending playlist edits.
if (detailModel.dropPlaylistEdit()) {
return
}
// Clear out any prior selections.
if (selectionModel.drop()) {
return
}
// Then try to navigate out of the explore navigation fragments (i.e Detail Views)
binding.exploreNavHost.findNavController().navigateUp()
}
/**
* Force this instance to update whether it's enabled or not. If there are no app components
* that the back button should close first, the instance is disabled and back navigation is
* delegated to the system.
*
* Normally, this listener would have just called the [MainActivity.onBackPressed] if there
* were no components to close, but that prevents adaptive back navigation from working on
* Android 14+, so we must do it this way.
*/
fun invalidateEnabled() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val exploreNavController = binding.exploreNavHost.findNavController()
isEnabled = queueSheetShown() || playbackSheetShown()
}
private fun playbackSheetShown() =
playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN
private fun queueSheetShown() =
queueSheetBehavior != null &&
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_COLLAPSED
}
private class DetailBackPressedCallback(private val detailModel: DetailViewModel) :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (detailModel.dropPlaylistEdit()) {
logD("Dropped playlist edits")
}
}
fun invalidateEnabled(playlistEdit: List<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 =
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
detailModel.editedPlaylist.value != null ||
selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId
}
}
}

View file

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

View file

@ -49,7 +49,15 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A [ListFragment] that shows information about an [Artist].
@ -153,7 +161,14 @@ class ArtistDetailFragment :
musicModel.addToPlaylist(currentArtist)
true
}
else -> false
R.id.action_share -> {
requireContext().share(currentArtist)
true
}
else -> {
logW("Unexpected menu item selected")
false
}
}
}
@ -222,11 +237,23 @@ class ArtistDetailFragment :
private fun updateArtist(artist: Artist?) {
if (artist == null) {
// Artist we were showing no longer exists.
logD("No artist to show, navigating away")
findNavController().navigateUp()
return
}
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
requireBinding().detailNormalToolbar.apply {
title = artist.name.resolve(requireContext())
// Disable options that make no sense with an empty artist
val playable = artist.songs.isNotEmpty()
if (!playable) {
logD("Artist is empty, disabling playback/playlist/share options")
}
menu.findItem(R.id.action_play_next).isEnabled = playable
menu.findItem(R.id.action_queue_add).isEnabled = playable
menu.findItem(R.id.action_playlist_add).isEnabled = playable
menu.findItem(R.id.action_share).isEnabled = playable
}
artistHeaderAdapter.setParent(artist)
}
@ -234,14 +261,14 @@ class ArtistDetailFragment :
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
val playingItem =
when (parent) {
// Always highlight a playing album if it's from this artist.
is Album -> parent
// Always highlight a playing album if it's from this artist, and if the currently
// playing song is contained within.
is Album -> parent.takeIf { song?.album == it }
// If the parent is the artist itself, use the currently playing song.
currentArtist -> song
// Nothing is playing from this artist.
else -> null
}
artistListAdapter.setPlaying(playingItem, isPlaying)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -22,8 +22,8 @@ import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.R
import com.google.android.material.textfield.TextInputEditText
import org.oxycblt.auxio.R
/**
* A [TextInputEditText] that deliberately restricts all input except for selection. This will work

View file

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

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
@ -91,6 +92,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
logD("Artist is empty, disabling genres and playback")
binding.detailSubhead.isVisible = false
binding.detailPlayButton.isEnabled = false
binding.detailShuffleButton.isEnabled = false

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.detail.header
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
@ -47,6 +48,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
* @param parent The new [MusicParent] to show.
*/
fun setParent(parent: T) {
logD("Updating parent [old: $currentParent new: $parent]")
currentParent = parent
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.
*/
protected fun rebindParent() {
logD("Rebinding parent")
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.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Playlist] information.
@ -57,6 +58,7 @@ class PlaylistDetailHeaderAdapter(private val listener: Listener) :
// Nothing to do.
return
}
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
editedPlaylist = songs
rebindParent()
}
@ -83,8 +85,16 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener
) {
// TODO: Debug perpetually re-binding images
binding.detailCover.bind(playlist, editedPlaylist)
if (editedPlaylist != null) {
logD("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
@ -103,12 +113,17 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
logD("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton.apply {
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
isEnabled = playable
setOnClickListener { listener.onPlay() }
}
binding.detailShuffleButton.apply {
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
isEnabled = playable
setOnClickListener { listener.onShuffle() }
}
}

View file

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

View file

@ -29,7 +29,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
@ -107,7 +110,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
binding.parentImage.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {
@ -159,7 +162,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying
binding.songAlbumCover.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {

View file

@ -31,8 +31,10 @@ import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.recycler.*
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater

View file

@ -27,6 +27,7 @@ import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
@ -47,6 +48,7 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
@ -97,6 +99,7 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
// Nothing to do.
return
}
logD("Updating editing state [old: $isEditing new: $editing]")
this.isEditing = editing
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
}
@ -213,7 +216,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
override val delete = binding.background
override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0
}
@ -223,7 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
LayerDrawable(
arrayOf(
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
},
background))
}
@ -253,7 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.interactBody.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying
binding.songAlbumCover.setPlaying(isPlaying)
}
override fun updateEditing(editing: Boolean) {

View file

@ -24,7 +24,8 @@ import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,7 +37,12 @@ import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -91,14 +91,15 @@ class TabCustomizeDialog :
// We will need the exact index of the tab to update on in order to
// notify the adapter of the change.
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
val tab = tabAdapter.tabs[index]
tabAdapter.setTab(
index,
when (tab) {
val old = tabAdapter.tabs[index]
val new =
when (old) {
// Invert the visibility of the tab
is Tab.Visible -> Tab.Invisible(tab.mode)
is Tab.Invisible -> Tab.Visible(tab.mode)
})
is Tab.Visible -> Tab.Invisible(old.mode)
is Tab.Invisible -> Tab.Visible(old.mode)
}
logD("Flipping tab visibility [from: $old to: $new]")
tabAdapter.setTab(index, new)
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =

View file

@ -63,7 +63,9 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
throw IllegalStateException()
}
// We use a custom drag handle, so disable the long press action.
override fun isLongPressDragEnabled() = false

View file

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

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.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.image.extractor.*
@Module
@InstallIn(SingletonComponent::class)

View file

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

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

View file

@ -43,6 +43,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlin.math.min
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
@ -50,11 +51,16 @@ import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.logE
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor
@Inject
constructor(
@ -62,28 +68,69 @@ constructor(
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param songs The [Song]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/
suspend fun extract(songs: List<Song>, size: Size): FetchResult? {
val albums = computeAlbumOrdering(songs)
val albums = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>()
for (album in albums) {
openInputStream(album)?.let(streams::add)
openCoverInputStream(album)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
return createMosaic(streams, size)
// Make sure we free the InputStreams once we've transformed them into a mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
}
return streams.firstOrNull()?.let { stream ->
SourceResult(
source = ImageSource(stream.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceResult(
source = ImageSource(first.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
fun computeAlbumOrdering(songs: List<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 {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
@ -91,7 +138,7 @@ constructor(
CoverMode.QUALITY -> extractQualityCover(album)
}
} catch (e: Exception) {
logW("Unable to extract album cover due to an error: $e")
logE("Unable to extract album cover due to an error: $e")
null
}
@ -148,7 +195,6 @@ constructor(
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
logD("Front cover found")
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
@ -164,7 +210,7 @@ constructor(
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
private fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
@ -184,10 +230,9 @@ constructor(
break
}
// Run the bitmap through a transform to reflect the configuration of other images.
val bitmap =
SquareFrameTransform.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
@ -206,14 +251,27 @@ constructor(
dataSource = DataSource.DISK)
}
/**
* Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided.
*/
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
private fun cropBitmap(input: Bitmap, size: Size): Bitmap {
// Find the smaller dimension and then take a center portion of the image that
// has that size.
val dstSize = min(input.width, input.height)
val x = (input.width - dstSize) / 2
val y = (input.height - dstSize) / 2
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
val desiredWidth = size.width.pxOrElse { dstSize }
val desiredHeight = size.height.pxOrElse { dstSize }
if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it.
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
}
return dst
}
}

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
// TODO: Consider breaking this up into sealed classes for individual adapters
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
interface Item

View file

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

View file

@ -22,8 +22,13 @@ import androidx.annotation.IdRes
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc

View file

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

View file

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

View file

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

View file

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

View file

@ -31,11 +31,16 @@ import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areNamesTheSame
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
@ -59,7 +64,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying
binding.songAlbumCover.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {
@ -109,7 +114,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
binding.parentImage.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {
@ -169,7 +174,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
binding.parentImage.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {
@ -226,7 +231,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
binding.parentImage.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {
@ -283,7 +288,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
binding.parentImage.setPlaying(isPlaying)
}
override fun updateSelectionIndicator(isSelected: Boolean) {
@ -325,7 +330,6 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
* @param basicHeader The new [BasicHeader] to bind.
*/
fun bind(basicHeader: BasicHeader) {
logD(binding.context.getString(basicHeader.titleRes))
binding.title.text = binding.context.getString(basicHeader.titleRes)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.fs.Directory
import org.oxycblt.auxio.music.fs.MusicDirectories
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
/**
* User configuration specific to music system.
@ -231,8 +232,14 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged()
getString(R.string.set_key_observing) -> listener.onObservingChanged()
getString(R.string.set_key_auto_sort_names) -> {
logD("Dispatching indexing setting change for $key")
listener.onIndexingSettingChanged()
}
getString(R.string.set_key_observing) -> {
logD("Dispatching observing setting change")
listener.onObservingChanged()
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -20,13 +20,21 @@ package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.fs.toCoverUri
import org.oxycblt.auxio.music.info.*
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
import org.oxycblt.auxio.music.metadata.parseMultiValue
import org.oxycblt.auxio.util.nonZeroOrNull
@ -85,9 +93,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
override val album: Album
get() = unlikelyToBeNull(_album)
override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode()
private val hashCode = 31 * uid.hashCode() + rawSong.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is SongImpl && uid == other.uid && rawSong == other.rawSong
override fun toString() = "Song(uid=$uid, name=$name)"
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
@ -237,44 +248,61 @@ class AlbumImpl(
update(rawAlbum.rawArtists.map { it.name })
}
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
override val dates = Date.Range.from(songs.mapNotNull { it.date })
override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = rawAlbum.mediaStoreId.toCoverUri()
override val durationMs: Long
override val dateAdded: Long
override fun hashCode(): Int {
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun equals(other: Any?) =
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
get() = _artists
private var hashCode = uid.hashCode()
init {
var totalDuration: Long = 0
var minDate: Date? = null
var maxDate: Date? = null
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in songs) {
song.link(this)
if (song.date != null) {
val min = minDate
if (min == null || song.date < min) {
minDate = song.date
}
val max = maxDate
if (max == null || song.date > max) {
maxDate = song.date
}
}
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
totalDuration += song.durationMs
}
val min = minDate
val max = maxDate
dates = if (min != null && max != null) Date.Range(min, max) else null
durationMs = totalDuration
dateAdded = earliestDateAdded
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
/**
* The [RawArtist] instances collated by the [Album]. The album artists of the song take
* priority, followed by the artists. If there are no artists, this field will be a single
@ -336,17 +364,48 @@ class ArtistImpl(
override val songs: List<Song>
override val albums: List<Album>
override val explicitAlbums: List<Album>
override val implicitAlbums: List<Album>
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
// the same UID but different songs are not equal.
override fun hashCode(): Int {
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is ArtistImpl &&
@ -354,35 +413,7 @@ class ArtistImpl(
rawArtist == other.rawArtist &&
songs == other.songs
override lateinit var genres: List<Genre>
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
}
override fun toString() = "Artist(uid=$uid, name=$name)"
/**
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
@ -393,7 +424,8 @@ class ArtistImpl(
* [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list.
*/
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist)
fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
rawArtists.indexOfFirst { it.key == rawArtist.key }
/**
* Perform final validation and organization on this instance.
@ -427,19 +459,10 @@ class GenreImpl(
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
?: Name.Unknown(R.string.def_genre)
override val albums: List<Album>
override val artists: List<Artist>
override val durationMs: Long
override fun hashCode(): Int {
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun equals(other: Any?) =
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
private var hashCode = uid.hashCode()
init {
val distinctAlbums = mutableSetOf<Album>()
@ -453,14 +476,19 @@ class GenreImpl(
totalDuration += song.durationMs
}
albums =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.albums(distinctAlbums)
.sortedByDescending { album -> album.songs.count { it.genres.contains(this) } }
artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists)
durationMs = totalDuration
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
/**
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
* This can be used to create a consistent ordering within child [Genre] lists based on the
@ -470,7 +498,8 @@ class GenreImpl(
* [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list.
*/
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre)
fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
rawGenres.indexOfFirst { it.key == rawGenre.key }
/**
* Perform final validation and organization on this instance.

View file

@ -19,7 +19,9 @@
package org.oxycblt.auxio.music.device
import java.util.UUID
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.Directory
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType
@ -111,28 +113,35 @@ data class RawAlbum(
/** @see RawArtist.name */
val rawArtists: List<RawArtist>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
val key = Key(this)
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
/** Exposed information that denotes [RawAlbum] uniqueness. */
data class Key(val value: RawAlbum) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
override fun hashCode() = hashCode
// Cache the hash-code for HashMap efficiency.
private val hashCode =
value.musicBrainzId?.hashCode()
?: (31 * value.name.lowercase().hashCode() + value.rawArtists.hashCode())
override fun equals(other: Any?) =
other is RawAlbum &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
value.musicBrainzId != null && other.value.musicBrainzId != null ->
value.musicBrainzId == other.value.musicBrainzId
value.musicBrainzId == null && other.value.musicBrainzId == null ->
other.value.name.equals(other.value.name, true) &&
other.value.rawArtists == other.value.rawArtists
else -> false
}
}
}
/**
@ -149,33 +158,42 @@ data class RawArtist(
/** @see Music.name */
val sortName: String? = null
) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
val key = Key(this)
// Cache the hashCode for HashMap efficiency.
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
/**
* Allows [RawArtist]s to be compared by "fundamental" information that is unlikely to change on
* an item-by-item
*/
data class Key(val value: RawArtist) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
// Cache the hashCode for HashMap efficiency.
private val hashCode = value.musicBrainzId?.hashCode() ?: value.name?.lowercase().hashCode()
override fun hashCode() = hashCode
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
override fun equals(other: Any?) =
other is RawArtist &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
else -> false
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Key &&
when {
value.musicBrainzId != null && other.value.musicBrainzId != null ->
value.musicBrainzId == other.value.musicBrainzId
value.musicBrainzId == null && other.value.musicBrainzId == null ->
when {
value.name != null && other.value.name != null ->
value.name.equals(other.value.name, true)
value.name == null && other.value.name == null -> true
else -> false
}
else -> false
}
}
}
/**
@ -187,20 +205,24 @@ data class RawGenre(
/** @see Music.name */
val name: String? = null
) {
val key = Key(this)
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
data class Key(val value: RawGenre) {
// Cache the hashCode for HashMap efficiency.
private val hashCode = value.name?.lowercase().hashCode()
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
override fun hashCode() = hashCode
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is RawGenre &&
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
override fun equals(other: Any?) =
other is Key &&
when {
value.name != null && other.value.name != null ->
value.name.equals(other.value.name, true)
value.name == null && other.value.name == null -> true
else -> false
}
}
}

View file

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

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
* obtained.
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Get around to simplifying this
*/
data class MimeType(val fromExtension: String, val fromFormat: String?) {
/**

View file

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

View file

@ -24,6 +24,7 @@ import java.text.SimpleDateFormat
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.nonZeroOrNull
/**
@ -51,33 +52,30 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
* be properly localized.
*/
fun resolveDate(context: Context): String {
if (month != null) {
// Parse a date format from an ISO-ish format
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
format.applyPattern("yyyy-MM")
val date =
try {
format.parse("$year-$month")
} catch (e: ParseException) {
null
}
if (date != null) {
// Reformat as a readable month and year
format.applyPattern("MMM yyyy")
return format.format(date)
}
}
fun resolve(context: Context) =
// Unable to create fine-grained date, just format as a year.
return context.getString(R.string.fmt_number, year)
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
private fun resolveFineGrained(): String? {
// We can't directly load a date with our own
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
format.applyPattern("yyyy-MM")
val date =
try {
format.parse("$year-$month")
} catch (e: ParseException) {
logE("Unable to parse fine-grained date: $e")
return null
}
// Reformat as a readable month and year
format.applyPattern("MMM yyyy")
return format.format(date)
}
override fun hashCode() = tokens.hashCode()
override fun equals(other: Any?) = other is Date && compareTo(other) == 0
override fun hashCode() = tokens.hashCode()
override fun toString() = StringBuilder().appendDate().toString()
override fun compareTo(other: Date): Int {
for (i in 0 until max(tokens.size, other.tokens.size)) {
val ai = tokens.getOrNull(i)
@ -98,8 +96,6 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
return 0
}
override fun toString() = StringBuilder().appendDate().toString()
private fun StringBuilder.appendDate(): StringBuilder {
// Construct an ISO-8601 date, dropping precision that doesn't exist.
append(year.toStringFixed(4))
@ -120,13 +116,15 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
*
* @author Alexander Capehart
*/
class Range
private constructor(
class Range(
/** The earliest [Date] in the range. */
val min: Date,
/** the latest [Date] in the range. May be the same as [min]. ] */
val max: Date
) : Comparable<Range> {
init {
check(min <= max) { "Min date must be <= max date" }
}
/**
* Resolve this instance into a human-readable date range.
@ -139,9 +137,9 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
fun resolveDate(context: Context) =
if (min != max) {
context.getString(
R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context))
R.string.fmt_date_range, min.resolve(context), max.resolve(context))
} else {
min.resolveDate(context)
min.resolve(context)
}
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max
@ -149,35 +147,6 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
override fun hashCode() = 31 * max.hashCode() + min.hashCode()
override fun compareTo(other: Range) = min.compareTo(other.min)
companion object {
/**
* Create a [Range] from the given list of [Date]s.
*
* @param dates The [Date]s to use.
* @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s,
* null is returned.
*/
fun from(dates: List<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 {

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.
*/
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 hashCode() = number.hashCode()
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)
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 =
name
// Remove excess punctuation from the string, as those u
@ -201,6 +203,7 @@ private data class IntelligentKnownName(override val raw: String, override val s
// Separate each token into their numeric and lexicographic counterparts.
if (token.first().isDigit()) {
// The digit string comparison breaks with preceding zero digits, remove those
// TODO: Handle zero digits in other languages
val digits = token.trimStart('0').ifEmpty { token }
// Other languages have other types of digit strings, still use collation keys
collationKey = COLLATOR.getCollationKey(digits)

View file

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@ import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.util.logD
/**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
@ -52,6 +53,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
// producing similar throughput's to other kinds of manual metadata extraction.
val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
logD("Beginning primary extraction loop")
for (incompleteRawSong in incompleteSongs) {
spin@ while (true) {
for (i in tagWorkerPool.indices) {
@ -71,6 +74,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
}
}
logD("All incomplete songs exhausted, starting cleanup loop")
do {
var ongoingTasks = false
for (i in tagWorkerPool.indices) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,6 +31,9 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
/**
* A [ViewModel] managing the state of the playlist picker dialogs.
@ -84,6 +87,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
}
logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}")
_currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs
@ -91,6 +96,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
.ifEmpty { null }
.also { refreshChoicesWith = it }
}
logD("Updated songs to add: ${_currentSongsToAdd.value?.size} songs")
}
val chosenName = _chosenName.value
@ -102,6 +108,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
// Nothing to do.
}
}
logD("Updated chosen name to $chosenName")
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
}
@ -119,19 +126,34 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
* @param songUids The [Music.UID]s of songs to be present in the playlist.
*/
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = songUids.mapNotNull(deviceLibrary::findSong)
logD("Opening ${songUids.size} songs to create a playlist from")
val userLibrary = musicRepository.userLibrary ?: return
var i = 1
while (true) {
val possibleName = context.getString(R.string.fmt_def_playlist, i)
if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) {
_currentPendingPlaylist.value = PendingPlaylist(possibleName, songs)
return
val songs =
musicRepository.deviceLibrary
?.let { songUids.mapNotNull(it::findSong) }
?.also(::refreshPlaylistChoices)
val possibleName =
musicRepository.userLibrary?.let {
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
var i = 1
var possibleName: String
do {
possibleName = context.getString(R.string.fmt_def_playlist, i)
logD("Trying $possibleName as a playlist name")
++i
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
logD("$possibleName is unique, using it as the playlist name")
possibleName
}
_currentPendingPlaylist.value =
if (possibleName != null && songs != null) {
PendingPlaylist(possibleName, songs)
} else {
logW("Given song UIDs to create were invalid")
null
}
++i
}
}
/**
@ -140,7 +162,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
* @param playlistUid The [Music.UID]s of the [Playlist] to rename.
*/
fun setPlaylistToRename(playlistUid: Music.UID) {
logD("Opening playlist $playlistUid to rename")
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) {
logW("Given playlist UID to rename was invalid")
}
}
/**
@ -149,7 +175,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
* @param playlistUid The [Music.UID] of the [Playlist] to delete.
*/
fun setPlaylistToDelete(playlistUid: Music.UID) {
logD("Opening playlist $playlistUid to delete")
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) {
logW("Given playlist UID to delete was invalid")
}
}
/**
@ -158,16 +188,25 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
* @param name The new user-inputted name, or null if not present.
*/
fun updateChosenName(name: String?) {
logD("Updating chosen name to $name")
_chosenName.value =
when {
name.isNullOrEmpty() -> ChosenName.Empty
name.isBlank() -> ChosenName.Blank
name.isNullOrEmpty() -> {
logE("Chosen name is empty")
ChosenName.Empty
}
name.isBlank() -> {
logE("Chosen name is blank")
ChosenName.Blank
}
else -> {
val trimmed = name.trim()
val userLibrary = musicRepository.userLibrary
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
logD("Chosen name is valid")
ChosenName.Valid(trimmed)
} else {
logD("Chosen name already exists in library")
ChosenName.AlreadyExists(trimmed)
}
}
@ -180,14 +219,19 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
* @param songUids The [Music.UID]s of songs to add to a playlist.
*/
fun setSongsToAdd(songUids: Array<Music.UID>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = songUids.mapNotNull(deviceLibrary::findSong)
_currentSongsToAdd.value = songs
refreshPlaylistChoices(songs)
logD("Opening ${songUids.size} songs to add to a playlist")
_currentSongsToAdd.value =
musicRepository.deviceLibrary
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
?.also(::refreshPlaylistChoices)
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
logW("Given song UIDs to add were (partially) invalid")
}
}
private fun refreshPlaylistChoices(songs: List<Song>) {
val userLibrary = musicRepository.userLibrary ?: return
logD("Refreshing playlist choices")
_playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
val songSet = it.songs.toSet()

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -86,7 +87,9 @@ class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding
}
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
}
}

View file

@ -28,12 +28,17 @@ import android.os.PowerManager
import android.provider.MediaStore
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import java.lang.Runnable
import java.util.*
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.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.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
@ -119,6 +124,7 @@ class IndexerService :
// --- CONTROLLER CALLBACKS ---
override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job")
// Cancel the previous music loading job.
currentIndexJob?.cancel()
// Start a new music loading job on a co-routine.
@ -132,6 +138,7 @@ class IndexerService :
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
logD("Music changed, updating shared objects")
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// 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.
// 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.
logD("Need to observe, staying in foreground")
if (!foregroundManager.tryStartForeground(observingNotification)) {
logD("Notification changed, re-posting notification")
observingNotification.post()
}
} else {
// Not observing and done loading, exit foreground.
logD("Exiting foreground")
foregroundManager.tryStopForeground()
}
// 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
// the music loading process ends.
if (currentIndexJob == null) {
logD("Not loading, updating idle session")
updateIdleSession()
}
}
@ -269,6 +280,7 @@ class IndexerService :
// 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.
if (musicSettings.shouldBeObserving) {
logD("MediaStore changed, starting re-index")
requestIndex(true)
}
}

View file

@ -18,7 +18,11 @@
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.info.Name
@ -29,8 +33,17 @@ private constructor(
override val songs: List<Song>
) : Playlist {
override val durationMs = songs.sumOf { it.durationMs }
override val albums =
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
private var hashCode = uid.hashCode()
init {
hashCode = 31 * hashCode + name.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun equals(other: Any?) =
other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs
override fun hashCode() = hashCode
override fun toString() = "Playlist(uid=$uid, name=$name)"
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
@ -55,16 +68,6 @@ private constructor(
*/
inline fun edit(edits: MutableList<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 {
/**
* Create a new instance with a novel UID.

View file

@ -18,7 +18,12 @@
package org.oxycblt.auxio.music.user
import androidx.room.*
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Junction
import androidx.room.PrimaryKey
import androidx.room.Relation
import org.oxycblt.auxio.music.Music
/**

View file

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

View file

@ -18,7 +18,14 @@
package org.oxycblt.auxio.music.user
import androidx.room.*
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.Music
/**

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: This whole system is very jankily designed, perhaps it's time for a refactor?
* TODO: Unwind this into ViewModel-specific actions, and then reference those.
*/
class NavigationViewModel : ViewModel() {
private val _mainNavigationAction = MutableEvent<MainNavigationAction>()
@ -96,6 +96,7 @@ class NavigationViewModel : ViewModel() {
* dialog will be shown.
*/
fun exploreNavigateToParentArtist(song: Song) {
logD("Navigating to parent artist of $song")
exploreNavigateToParentArtistImpl(song, song.artists)
}
@ -106,6 +107,7 @@ class NavigationViewModel : ViewModel() {
* dialog will be shown.
*/
fun exploreNavigateToParentArtist(album: Album) {
logD("Navigating to parent artist of $album")
exploreNavigateToParentArtistImpl(album, album.artists)
}

View file

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

View file

@ -23,7 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
/**
* A [ViewModel] that stores the current information required for navigation picker dialogs
@ -58,6 +63,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
}
else -> null
}
logD("Updated artist choices: ${_artistChoices.value}")
}
override fun onCleared() {
@ -71,12 +77,22 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
*/
fun setArtistChoiceUid(itemUid: Music.UID) {
logD("Opening navigation choices for $itemUid")
// Support Songs and Albums, which have parent artists.
_artistChoices.value =
when (val music = musicRepository.find(itemUid)) {
is Song -> SongArtistNavigationChoices(music)
is Album -> AlbumArtistNavigationChoices(music)
else -> null
is Song -> {
logD("Creating navigation choices for song")
SongArtistNavigationChoices(music)
}
is Album -> {
logD("Creating navigation choices for album")
AlbumArtistNavigationChoices(music)
}
else -> {
logD("Given song/album UID was invalid")
null
}
}
}
}

View file

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

View file

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

View file

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

View file

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

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