commit
6031fb2890
213 changed files with 3738 additions and 2006 deletions
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
|
@ -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
|
||||
|
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -1,5 +1,32 @@
|
|||
# Changelog
|
||||
|
||||
## 3.1.1
|
||||
|
||||
#### What's New
|
||||
- Added ability to share a track
|
||||
|
||||
#### What's Improved
|
||||
- Tracks with no disc number now default to "No Disc" instead of "Disc 1"
|
||||
- Albums implicitly linked only via "artist" tags are now placed in a special
|
||||
"appears on" section in the artist view
|
||||
- Album covers that are not 1:1 aspect ratio are no longer cropped
|
||||
- Optimized library creation phase of the music loading process
|
||||
|
||||
#### What's Fixed
|
||||
- Prevented options such as "Add to queue" from being selected on empty artists and playlists
|
||||
- Fixed issue where an item would be indicated as "playing" after playback ended
|
||||
- Items should no longer be indicated as playing if the currently playing song is not contained
|
||||
within it
|
||||
- Fixed blurry playing indicator in album/artist/genre/playlist items
|
||||
- Fixed incorrect songs being displayed when adding albums to the end of the queue
|
||||
- Fixed freezing occuring when scrolling through large music libraries
|
||||
- Fixed app not responding once music loading completes for large libraries
|
||||
- Fixed crash when the last song of the queue gets removed while playing
|
||||
- Fixed playback UI and notification not re-appearing after playback ends
|
||||
|
||||
#### What's Changed
|
||||
- Android Lollipop and Marshmallow support have been dropped
|
||||
|
||||
## 3.1.0
|
||||
|
||||
#### What's New
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
441
app/src/main/java/org/oxycblt/auxio/image/CoverView.kt
Normal file
441
app/src/main/java/org/oxycblt/auxio/image/CoverView.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?) {
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue