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
|
cache: gradle
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
- name: Test app with Gradle
|
# - name: Test app with Gradle
|
||||||
run: ./gradlew app:testDebug
|
# run: ./gradlew app:testDebug
|
||||||
- name: Build debug APK with Gradle
|
- name: Build debug APK with Gradle
|
||||||
run: ./gradlew app:packageDebug
|
run: ./gradlew app:packageDebug
|
||||||
- name: Upload debug APK artifact
|
- name: Upload debug APK artifact
|
||||||
|
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -1,5 +1,32 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 3.1.1
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Added ability to share a track
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Tracks with no disc number now default to "No Disc" instead of "Disc 1"
|
||||||
|
- Albums implicitly linked only via "artist" tags are now placed in a special
|
||||||
|
"appears on" section in the artist view
|
||||||
|
- Album covers that are not 1:1 aspect ratio are no longer cropped
|
||||||
|
- Optimized library creation phase of the music loading process
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Prevented options such as "Add to queue" from being selected on empty artists and playlists
|
||||||
|
- Fixed issue where an item would be indicated as "playing" after playback ended
|
||||||
|
- Items should no longer be indicated as playing if the currently playing song is not contained
|
||||||
|
within it
|
||||||
|
- Fixed blurry playing indicator in album/artist/genre/playlist items
|
||||||
|
- Fixed incorrect songs being displayed when adding albums to the end of the queue
|
||||||
|
- Fixed freezing occuring when scrolling through large music libraries
|
||||||
|
- Fixed app not responding once music loading completes for large libraries
|
||||||
|
- Fixed crash when the last song of the queue gets removed while playing
|
||||||
|
- Fixed playback UI and notification not re-appearing after playback ends
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
- Android Lollipop and Marshmallow support have been dropped
|
||||||
|
|
||||||
## 3.1.0
|
## 3.1.0
|
||||||
|
|
||||||
#### What's New
|
#### What's New
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
<h1 align="center"><b>Auxio</b></h1>
|
<h1 align="center"><b>Auxio</b></h1>
|
||||||
<h4 align="center">A simple, rational music player for android.</h4>
|
<h4 align="center">A simple, rational music player for android.</h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.0">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.1">
|
||||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.0&color=64B5F6&style=flat">
|
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.1&color=64B5F6&style=flat">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||||
|
@ -11,7 +11,7 @@
|
||||||
<a href="https://www.gnu.org/licenses/gpl-3.0">
|
<a href="https://www.gnu.org/licenses/gpl-3.0">
|
||||||
<img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat">
|
<img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat">
|
||||||
</a>
|
</a>
|
||||||
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-1450A8?style=flat">
|
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-24%2B-1450A8?style=flat">
|
||||||
</p>
|
</p>
|
||||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
|
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
@ -20,10 +20,10 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.1.0"
|
versionName "3.1.1"
|
||||||
versionCode 30
|
versionCode 31
|
||||||
|
|
||||||
minSdk 21
|
minSdk 24
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
@ -86,13 +86,13 @@ dependencies {
|
||||||
// General
|
// General
|
||||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||||
implementation "androidx.core:core-ktx:1.10.1"
|
implementation "androidx.core:core-ktx:1.10.1"
|
||||||
implementation "androidx.activity:activity-ktx:1.7.1"
|
implementation "androidx.activity:activity-ktx:1.7.2"
|
||||||
implementation "androidx.fragment:fragment-ktx:1.5.7"
|
implementation "androidx.fragment:fragment-ktx:1.5.7"
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
implementation "androidx.recyclerview:recyclerview:1.3.0"
|
implementation "androidx.recyclerview:recyclerview:1.3.0"
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||||
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
|
implementation "androidx.viewpager2:viewpager2:1.1.0-beta02"
|
||||||
implementation 'androidx.core:core-ktx:1.10.1'
|
implementation 'androidx.core:core-ktx:1.10.1'
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
@ -125,7 +125,7 @@ dependencies {
|
||||||
implementation project(":media-lib-decoder-ffmpeg")
|
implementation project(":media-lib-decoder-ffmpeg")
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation 'io.coil-kt:coil-base:2.3.0'
|
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||||
|
|
||||||
// Material
|
// Material
|
||||||
// TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available
|
// TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available
|
||||||
|
|
|
@ -16,8 +16,6 @@
|
||||||
|
|
||||||
package com.google.android.material.bottomsheet;
|
package com.google.android.material.bottomsheet;
|
||||||
|
|
||||||
import com.google.android.material.R;
|
|
||||||
|
|
||||||
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
||||||
import static java.lang.Math.max;
|
import static java.lang.Math.max;
|
||||||
import static java.lang.Math.min;
|
import static java.lang.Math.min;
|
||||||
|
@ -44,6 +42,7 @@ import android.view.ViewGroup;
|
||||||
import android.view.ViewGroup.MarginLayoutParams;
|
import android.view.ViewGroup.MarginLayoutParams;
|
||||||
import android.view.ViewParent;
|
import android.view.ViewParent;
|
||||||
import android.view.accessibility.AccessibilityEvent;
|
import android.view.accessibility.AccessibilityEvent;
|
||||||
|
|
||||||
import androidx.annotation.FloatRange;
|
import androidx.annotation.FloatRange;
|
||||||
import androidx.annotation.IntDef;
|
import androidx.annotation.IntDef;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
@ -63,11 +62,14 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.Accessibilit
|
||||||
import androidx.core.view.accessibility.AccessibilityViewCommand;
|
import androidx.core.view.accessibility.AccessibilityViewCommand;
|
||||||
import androidx.customview.view.AbsSavedState;
|
import androidx.customview.view.AbsSavedState;
|
||||||
import androidx.customview.widget.ViewDragHelper;
|
import androidx.customview.widget.ViewDragHelper;
|
||||||
|
|
||||||
|
import com.google.android.material.R;
|
||||||
import com.google.android.material.internal.ViewUtils;
|
import com.google.android.material.internal.ViewUtils;
|
||||||
import com.google.android.material.internal.ViewUtils.RelativePadding;
|
import com.google.android.material.internal.ViewUtils.RelativePadding;
|
||||||
import com.google.android.material.resources.MaterialResources;
|
import com.google.android.material.resources.MaterialResources;
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable;
|
import com.google.android.material.shape.MaterialShapeDrawable;
|
||||||
import com.google.android.material.shape.ShapeAppearanceModel;
|
import com.google.android.material.shape.ShapeAppearanceModel;
|
||||||
|
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
|
@ -1334,6 +1336,19 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the target state of the bottom sheet if currently attempting to settle, or the current
|
||||||
|
* state otherwise.
|
||||||
|
* @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
|
||||||
|
* or {@link #STATE_DRAGGING}
|
||||||
|
*/
|
||||||
|
public int getTargetState() {
|
||||||
|
if (state != STATE_SETTLING) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return stateSettlingTracker.targetState;
|
||||||
|
}
|
||||||
|
|
||||||
void setStateInternal(@State int state) {
|
void setStateInternal(@State int state) {
|
||||||
if (this.state == state) {
|
if (this.state == state) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -16,20 +16,16 @@
|
||||||
|
|
||||||
package com.google.android.material.divider;
|
package com.google.android.material.divider;
|
||||||
|
|
||||||
import com.google.android.material.R;
|
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.res.TypedArray;
|
import android.content.res.TypedArray;
|
||||||
import android.graphics.Canvas;
|
import android.graphics.Canvas;
|
||||||
import android.graphics.Rect;
|
import android.graphics.Rect;
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.graphics.drawable.ShapeDrawable;
|
import android.graphics.drawable.ShapeDrawable;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration;
|
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.ColorInt;
|
import androidx.annotation.ColorInt;
|
||||||
import androidx.annotation.ColorRes;
|
import androidx.annotation.ColorRes;
|
||||||
import androidx.annotation.DimenRes;
|
import androidx.annotation.DimenRes;
|
||||||
|
@ -39,6 +35,11 @@ import androidx.annotation.Px;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.core.graphics.drawable.DrawableCompat;
|
import androidx.core.graphics.drawable.DrawableCompat;
|
||||||
import androidx.core.view.ViewCompat;
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.ItemDecoration;
|
||||||
|
|
||||||
|
import com.google.android.material.R;
|
||||||
import com.google.android.material.internal.ThemeEnforcement;
|
import com.google.android.material.internal.ThemeEnforcement;
|
||||||
import com.google.android.material.resources.MaterialResources;
|
import com.google.android.material.resources.MaterialResources;
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ import org.oxycblt.auxio.playback.system.PlaybackService
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.isNight
|
import org.oxycblt.auxio.util.isNight
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,8 +51,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* TODO: Unit testing
|
* TODO: Unit testing
|
||||||
* TODO: Fix UID naming
|
* TODO: Fix UID naming
|
||||||
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
|
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
|
||||||
* TODO: Add more logging
|
* TODO: Improve multi-threading support in shared objects
|
||||||
* TODO: Try to move on from synchronized and volatile in shared objs
|
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
@ -121,6 +121,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private fun startIntentAction(intent: Intent?): Boolean {
|
private fun startIntentAction(intent: Intent?): Boolean {
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
|
logD("No intent to handle")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,6 +130,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
// This is because onStart can run multiple times, and thus we really don't
|
// This is because onStart can run multiple times, and thus we really don't
|
||||||
// want to return false and override the original delayed action with a
|
// want to return false and override the original delayed action with a
|
||||||
// RestoreState action.
|
// RestoreState action.
|
||||||
|
logD("Already used this intent")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
intent.putExtra(KEY_INTENT_USED, true)
|
intent.putExtra(KEY_INTENT_USED, true)
|
||||||
|
@ -137,8 +139,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
||||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
|
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
|
||||||
else -> return false
|
else -> {
|
||||||
|
logW("Unexpected intent ${intent.action}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
logD("Translated intent to $action")
|
||||||
playbackModel.startAction(action)
|
playbackModel.startAction(action)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,11 +26,13 @@ import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.FragmentContainerView
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavDestination
|
import androidx.navigation.NavDestination
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.findNavController
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.google.android.material.R as MR
|
||||||
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import com.google.android.material.transition.MaterialFadeThrough
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
|
@ -50,13 +52,24 @@ import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.collect
|
||||||
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.context
|
||||||
|
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||||
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
|
import org.oxycblt.auxio.util.getDimen
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper around the home fragment that shows the playback fragment and controls the more
|
* A wrapper around the home fragment that shows the playback fragment and controls the more
|
||||||
* high-level navigation features.
|
* high-level navigation features.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*
|
||||||
|
* TODO: Break up the god navigation setup going on here
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainFragment :
|
class MainFragment :
|
||||||
|
@ -68,7 +81,10 @@ class MainFragment :
|
||||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
private val selectionModel: SelectionViewModel by activityViewModels()
|
private val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
private val callback = DynamicBackPressedCallback()
|
private var sheetBackCallback: SheetBackPressedCallback? = null
|
||||||
|
private var detailBackCallback: DetailBackPressedCallback? = null
|
||||||
|
private var selectionBackCallback: SelectionBackPressedCallback? = null
|
||||||
|
private var exploreBackCallback: ExploreBackPressedCallback? = null
|
||||||
private var lastInsets: WindowInsets? = null
|
private var lastInsets: WindowInsets? = null
|
||||||
private var elevationNormal = 0f
|
private var elevationNormal = 0f
|
||||||
private var initialNavDestinationChange = true
|
private var initialNavDestinationChange = true
|
||||||
|
@ -84,13 +100,38 @@ class MainFragment :
|
||||||
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
val playbackSheetBehavior =
|
||||||
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
|
val queueSheetBehavior =
|
||||||
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
|
|
||||||
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
|
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
|
||||||
|
|
||||||
|
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
|
||||||
|
// that instantiating these callbacks in their respective fragments would result in the
|
||||||
|
// correct order.
|
||||||
|
val sheetBackCallback =
|
||||||
|
SheetBackPressedCallback(
|
||||||
|
playbackSheetBehavior = playbackSheetBehavior,
|
||||||
|
queueSheetBehavior = queueSheetBehavior)
|
||||||
|
.also { sheetBackCallback = it }
|
||||||
|
val detailBackCallback =
|
||||||
|
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
||||||
|
val selectionBackCallback =
|
||||||
|
SelectionBackPressedCallback(selectionModel).also { selectionBackCallback = it }
|
||||||
|
val exploreBackCallback =
|
||||||
|
ExploreBackPressedCallback(binding.exploreNavHost).also { exploreBackCallback = it }
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
val context = requireActivity()
|
val context = requireActivity()
|
||||||
// Override the back pressed listener so we can map back navigation to collapsing
|
// Override the back pressed listener so we can map back navigation to collapsing
|
||||||
// navigation, navigation out of detail views, etc.
|
// navigation, navigation out of detail views, etc.
|
||||||
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
|
context.onBackPressedDispatcher.apply {
|
||||||
|
addCallback(viewLifecycleOwner, exploreBackCallback)
|
||||||
|
addCallback(viewLifecycleOwner, selectionBackCallback)
|
||||||
|
addCallback(viewLifecycleOwner, detailBackCallback)
|
||||||
|
addCallback(viewLifecycleOwner, sheetBackCallback)
|
||||||
|
}
|
||||||
|
|
||||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||||
lastInsets = insets
|
lastInsets = insets
|
||||||
|
@ -103,13 +144,10 @@ class MainFragment :
|
||||||
ViewCompat.setAccessibilityPaneTitle(
|
ViewCompat.setAccessibilityPaneTitle(
|
||||||
binding.queueSheet, context.getString(R.string.lbl_queue))
|
binding.queueSheet, context.getString(R.string.lbl_queue))
|
||||||
|
|
||||||
val queueSheetBehavior =
|
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
|
||||||
if (queueSheetBehavior != null) {
|
if (queueSheetBehavior != null) {
|
||||||
// Bottom sheet mode, set up click listeners.
|
// In portrait mode, set up click listeners on the stacked sheets.
|
||||||
val playbackSheetBehavior =
|
logD("Configuring stacked bottom sheets")
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
|
||||||
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
|
|
||||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
|
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
|
||||||
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
|
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
|
||||||
|
@ -118,14 +156,15 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Dual-pane mode, manually style the static queue sheet.
|
// Dual-pane mode, manually style the static queue sheet.
|
||||||
|
logD("Configuring dual-pane bottom sheet")
|
||||||
binding.queueSheet.apply {
|
binding.queueSheet.apply {
|
||||||
// Emulate the elevated bottom sheet style.
|
// Emulate the elevated bottom sheet style.
|
||||||
background =
|
background =
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||||
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
|
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
|
||||||
elevation = context.getDimen(R.dimen.elevation_normal)
|
elevation = context.getDimen(R.dimen.elevation_normal)
|
||||||
}
|
}
|
||||||
// Apply bar insets for the queue's RecyclerView to usee.
|
// Apply bar insets for the queue's RecyclerView to use.
|
||||||
setOnApplyWindowInsetsListener { v, insets ->
|
setOnApplyWindowInsetsListener { v, insets ->
|
||||||
v.updatePadding(top = insets.systemBarInsetsCompat.top)
|
v.updatePadding(top = insets.systemBarInsetsCompat.top)
|
||||||
insets
|
insets
|
||||||
|
@ -134,13 +173,15 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
|
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
|
||||||
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
|
collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled)
|
||||||
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
|
|
||||||
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
|
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
|
||||||
collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
|
collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
|
||||||
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
|
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
|
||||||
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
|
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
|
||||||
|
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
|
||||||
|
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
|
||||||
|
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
|
||||||
collectImmediately(playbackModel.song, ::updateSong)
|
collectImmediately(playbackModel.song, ::updateSong)
|
||||||
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
|
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
|
||||||
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
|
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
|
||||||
|
@ -165,6 +206,14 @@ class MainFragment :
|
||||||
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroyBinding(binding: FragmentMainBinding) {
|
||||||
|
super.onDestroyBinding(binding)
|
||||||
|
sheetBackCallback = null
|
||||||
|
detailBackCallback = null
|
||||||
|
selectionBackCallback = null
|
||||||
|
exploreBackCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPreDraw(): Boolean {
|
override fun onPreDraw(): Boolean {
|
||||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||||
// listener functionality. Just update all transitions before every draw. Should
|
// listener functionality. Just update all transitions before every draw. Should
|
||||||
|
@ -250,7 +299,8 @@ class MainFragment :
|
||||||
|
|
||||||
// Since the navigation listener is also reliant on the bottom sheets, we must also update
|
// Since the navigation listener is also reliant on the bottom sheets, we must also update
|
||||||
// it every frame.
|
// it every frame.
|
||||||
callback.invalidateEnabled()
|
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
|
||||||
|
.invalidateEnabled()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -263,6 +313,8 @@ class MainFragment :
|
||||||
// Drop the initial call by NavController that simply provides us with the current
|
// Drop the initial call by NavController that simply provides us with the current
|
||||||
// destination. This would cause the selection state to be lost every time the device
|
// destination. This would cause the selection state to be lost every time the device
|
||||||
// rotates.
|
// rotates.
|
||||||
|
requireNotNull(exploreBackCallback) { "ExploreBackPressedCallback was not available" }
|
||||||
|
.invalidateEnabled()
|
||||||
if (!initialNavDestinationChange) {
|
if (!initialNavDestinationChange) {
|
||||||
initialNavDestinationChange = true
|
initialNavDestinationChange = true
|
||||||
return
|
return
|
||||||
|
@ -271,19 +323,15 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleMainNavigation(action: MainNavigationAction?) {
|
private fun handleMainNavigation(action: MainNavigationAction?) {
|
||||||
if (action == null) {
|
if (action != null) {
|
||||||
// Nothing to do.
|
when (action) {
|
||||||
return
|
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
|
||||||
|
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
|
||||||
|
is MainNavigationAction.Directions ->
|
||||||
|
findNavController().navigateSafe(action.directions)
|
||||||
|
}
|
||||||
|
navModel.mainNavigationAction.consume()
|
||||||
}
|
}
|
||||||
|
|
||||||
when (action) {
|
|
||||||
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
|
|
||||||
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
|
|
||||||
is MainNavigationAction.Directions ->
|
|
||||||
findNavController().navigateSafe(action.directions)
|
|
||||||
}
|
|
||||||
|
|
||||||
navModel.mainNavigationAction.consume()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleExploreNavigation(item: Music?) {
|
private fun handleExploreNavigation(item: Music?) {
|
||||||
|
@ -368,6 +416,7 @@ class MainFragment :
|
||||||
|
|
||||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
// Playback sheet is not expanded and not hidden, we can expand it.
|
||||||
|
logD("Expanding playback sheet")
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -378,6 +427,7 @@ class MainFragment :
|
||||||
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||||
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
||||||
// playback panel can eb shown.
|
// playback panel can eb shown.
|
||||||
|
logD("Collapsing queue sheet")
|
||||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -388,6 +438,7 @@ class MainFragment :
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||||
// Playback sheet (and possibly queue) needs to be collapsed.
|
// Playback sheet (and possibly queue) needs to be collapsed.
|
||||||
|
logD("Collapsing playback and queue sheets")
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
@ -399,7 +450,8 @@ class MainFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||||
|
logD("Unhiding and enabling playback sheet")
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||||
|
@ -416,10 +468,12 @@ class MainFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
|
if (playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
|
|
||||||
|
logD("Hiding and disabling playback and queue sheets")
|
||||||
|
|
||||||
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||||
queueSheetBehavior?.apply {
|
queueSheetBehavior?.apply {
|
||||||
isDraggable = false
|
isDraggable = false
|
||||||
|
@ -433,71 +487,86 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// TODO: Use targetState more
|
||||||
* A [OnBackPressedCallback] that overrides the back button to first navigate out of internal
|
|
||||||
* app components, such as the Bottom Sheets or Explore Navigation.
|
|
||||||
*/
|
|
||||||
private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
|
|
||||||
override fun handleOnBackPressed() {
|
|
||||||
val binding = requireBinding()
|
|
||||||
val playbackSheetBehavior =
|
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
|
||||||
val queueSheetBehavior =
|
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
|
||||||
|
|
||||||
|
private class SheetBackPressedCallback(
|
||||||
|
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
|
||||||
|
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
|
||||||
|
) : OnBackPressedCallback(false) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
// If expanded, collapse the queue sheet first.
|
// If expanded, collapse the queue sheet first.
|
||||||
if (queueSheetBehavior != null &&
|
if (queueSheetShown()) {
|
||||||
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
unlikelyToBeNull(queueSheetBehavior).state =
|
||||||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
logD("Collapsed queue sheet")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If expanded, collapse the playback sheet next.
|
// If expanded, collapse the playback sheet next.
|
||||||
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
if (playbackSheetShown()) {
|
||||||
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
|
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
logD("Collapsed playback sheet")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear out pending playlist edits.
|
|
||||||
if (detailModel.dropPlaylistEdit()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear out any prior selections.
|
|
||||||
if (selectionModel.drop()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then try to navigate out of the explore navigation fragments (i.e Detail Views)
|
|
||||||
binding.exploreNavHost.findNavController().navigateUp()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Force this instance to update whether it's enabled or not. If there are no app components
|
|
||||||
* that the back button should close first, the instance is disabled and back navigation is
|
|
||||||
* delegated to the system.
|
|
||||||
*
|
|
||||||
* Normally, this listener would have just called the [MainActivity.onBackPressed] if there
|
|
||||||
* were no components to close, but that prevents adaptive back navigation from working on
|
|
||||||
* Android 14+, so we must do it this way.
|
|
||||||
*/
|
|
||||||
fun invalidateEnabled() {
|
fun invalidateEnabled() {
|
||||||
val binding = requireBinding()
|
isEnabled = queueSheetShown() || playbackSheetShown()
|
||||||
val playbackSheetBehavior =
|
}
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
|
||||||
val queueSheetBehavior =
|
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
|
||||||
val exploreNavController = binding.exploreNavHost.findNavController()
|
|
||||||
|
|
||||||
|
private fun playbackSheetShown() =
|
||||||
|
playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
||||||
|
playbackSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_HIDDEN
|
||||||
|
|
||||||
|
private fun queueSheetShown() =
|
||||||
|
queueSheetBehavior != null &&
|
||||||
|
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
|
||||||
|
queueSheetBehavior.targetState != BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DetailBackPressedCallback(private val detailModel: DetailViewModel) :
|
||||||
|
OnBackPressedCallback(false) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (detailModel.dropPlaylistEdit()) {
|
||||||
|
logD("Dropped playlist edits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateEnabled(playlistEdit: List<Song>?) {
|
||||||
|
isEnabled = playlistEdit != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class SelectionBackPressedCallback(
|
||||||
|
private val selectionModel: SelectionViewModel
|
||||||
|
) : OnBackPressedCallback(false) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (selectionModel.drop()) {
|
||||||
|
logD("Dropped selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateEnabled(selection: List<Music>) {
|
||||||
|
isEnabled = selection.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ExploreBackPressedCallback(
|
||||||
|
private val exploreNavHost: FragmentContainerView
|
||||||
|
) : OnBackPressedCallback(false) {
|
||||||
|
// Note: We cannot cache the NavController in a variable since it's current destination
|
||||||
|
// value goes stale for some reason.
|
||||||
|
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
exploreNavHost.findNavController().navigateUp()
|
||||||
|
logD("Forwarded back navigation to explore nav host")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateEnabled() {
|
||||||
|
val exploreNavController = exploreNavHost.findNavController()
|
||||||
isEnabled =
|
isEnabled =
|
||||||
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
exploreNavController.currentDestination?.id !=
|
||||||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
exploreNavController.graph.startDestinationId
|
||||||
detailModel.editedPlaylist.value != null ||
|
|
||||||
selectionModel.selected.value.isNotEmpty() ||
|
|
||||||
exploreNavController.currentDestination?.id !=
|
|
||||||
exploreNavController.graph.startDestinationId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,7 +51,16 @@ import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.canScroll
|
||||||
|
import org.oxycblt.auxio.util.collect
|
||||||
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows information about an [Album].
|
* A [ListFragment] that shows information about an [Album].
|
||||||
|
@ -156,7 +165,14 @@ class AlbumDetailFragment :
|
||||||
musicModel.addToPlaylist(currentAlbum)
|
musicModel.addToPlaylist(currentAlbum)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
R.id.action_share -> {
|
||||||
|
requireContext().share(currentAlbum)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,7 +226,7 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
private fun updateAlbum(album: Album?) {
|
private fun updateAlbum(album: Album?) {
|
||||||
if (album == null) {
|
if (album == null) {
|
||||||
// Album we were showing no longer exists.
|
logD("No album to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -219,12 +235,8 @@ class AlbumDetailFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
|
albumListAdapter.setPlaying(
|
||||||
albumListAdapter.setPlaying(song, isPlaying)
|
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
|
||||||
} else {
|
|
||||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
|
||||||
albumListAdapter.setPlaying(null, isPlaying)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNavigation(item: Music?) {
|
private fun handleNavigation(item: Music?) {
|
||||||
|
@ -291,7 +303,7 @@ class AlbumDetailFragment :
|
||||||
boxStart: Int,
|
boxStart: Int,
|
||||||
boxEnd: Int,
|
boxEnd: Int,
|
||||||
snapPreference: Int
|
snapPreference: Int
|
||||||
): Int =
|
) =
|
||||||
(boxStart + (boxEnd - boxStart) / 2) -
|
(boxStart + (boxEnd - boxStart) / 2) -
|
||||||
(viewStart + (viewEnd - viewStart) / 2)
|
(viewStart + (viewEnd - viewStart) / 2)
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,15 @@ import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.collect
|
||||||
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows information about an [Artist].
|
* A [ListFragment] that shows information about an [Artist].
|
||||||
|
@ -153,7 +161,14 @@ class ArtistDetailFragment :
|
||||||
musicModel.addToPlaylist(currentArtist)
|
musicModel.addToPlaylist(currentArtist)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
R.id.action_share -> {
|
||||||
|
requireContext().share(currentArtist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,11 +237,23 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
private fun updateArtist(artist: Artist?) {
|
private fun updateArtist(artist: Artist?) {
|
||||||
if (artist == null) {
|
if (artist == null) {
|
||||||
// Artist we were showing no longer exists.
|
logD("No artist to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
|
requireBinding().detailNormalToolbar.apply {
|
||||||
|
title = artist.name.resolve(requireContext())
|
||||||
|
|
||||||
|
// Disable options that make no sense with an empty artist
|
||||||
|
val playable = artist.songs.isNotEmpty()
|
||||||
|
if (!playable) {
|
||||||
|
logD("Artist is empty, disabling playback/playlist/share options")
|
||||||
|
}
|
||||||
|
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_playlist_add).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_share).isEnabled = playable
|
||||||
|
}
|
||||||
artistHeaderAdapter.setParent(artist)
|
artistHeaderAdapter.setParent(artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -234,14 +261,14 @@ class ArtistDetailFragment :
|
||||||
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
||||||
val playingItem =
|
val playingItem =
|
||||||
when (parent) {
|
when (parent) {
|
||||||
// Always highlight a playing album if it's from this artist.
|
// Always highlight a playing album if it's from this artist, and if the currently
|
||||||
is Album -> parent
|
// playing song is contained within.
|
||||||
|
is Album -> parent.takeIf { song?.album == it }
|
||||||
// If the parent is the artist itself, use the currently playing song.
|
// If the parent is the artist itself, use the currently playing song.
|
||||||
currentArtist -> song
|
currentArtist -> song
|
||||||
// Nothing is playing from this artist.
|
// Nothing is playing from this artist.
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
artistListAdapter.setPlaying(playingItem, isPlaying)
|
artistListAdapter.setPlaying(playingItem, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||||
import org.oxycblt.auxio.util.getInteger
|
import org.oxycblt.auxio.util.getInteger
|
||||||
import org.oxycblt.auxio.util.lazyReflectedField
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||||
|
@ -77,7 +78,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
|
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
|
||||||
// We can never properly initialize the title view's state before draw time,
|
// We can never properly initialize the title view's state before draw time,
|
||||||
// so we just set it's alpha to 0f to produce a less jarring initialization
|
// so we just set it's alpha to 0f to produce a less jarring initialization
|
||||||
// animation..
|
// animation.
|
||||||
alpha = 0f
|
alpha = 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,12 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
if (titleShown == visible) return
|
if (titleShown == visible) return
|
||||||
titleShown = visible
|
titleShown = visible
|
||||||
|
|
||||||
val titleAnimator = titleAnimator
|
|
||||||
if (titleAnimator != null) {
|
|
||||||
titleAnimator.cancel()
|
|
||||||
this.titleAnimator = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
|
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
|
||||||
// the title view's alpha instead of the AppBarLayout's elevation.
|
// the title view's alpha instead of the AppBarLayout's elevation.
|
||||||
val titleView = findTitleView()
|
val titleView = findTitleView()
|
||||||
|
@ -126,7 +121,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.titleAnimator =
|
logD("Changing title visibility [from: $from to: $to]")
|
||||||
|
titleAnimator?.cancel()
|
||||||
|
titleAnimator =
|
||||||
ValueAnimator.ofFloat(from, to).apply {
|
ValueAnimator.ofFloat(from, to).apply {
|
||||||
addUpdateListener { titleView.alpha = it.animatedValue as Float }
|
addUpdateListener { titleView.alpha = it.animatedValue as Float }
|
||||||
duration =
|
duration =
|
||||||
|
|
|
@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.detail.list.DiscHeader
|
||||||
import org.oxycblt.auxio.detail.list.EditHeader
|
import org.oxycblt.auxio.detail.list.EditHeader
|
||||||
import org.oxycblt.auxio.detail.list.SortHeader
|
import org.oxycblt.auxio.detail.list.SortHeader
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
import org.oxycblt.auxio.list.BasicHeader
|
||||||
|
@ -37,12 +38,22 @@ import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
import org.oxycblt.auxio.music.info.ReleaseType
|
||||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.Event
|
||||||
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
||||||
|
@ -60,7 +71,7 @@ constructor(
|
||||||
private val playbackSettings: PlaybackSettings
|
private val playbackSettings: PlaybackSettings
|
||||||
) : ViewModel(), MusicRepository.UpdateListener {
|
) : ViewModel(), MusicRepository.UpdateListener {
|
||||||
// --- SONG ---
|
// --- SONG ---
|
||||||
|
|
||||||
private var currentSongJob: Job? = null
|
private var currentSongJob: Job? = null
|
||||||
|
|
||||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||||
|
@ -219,9 +230,9 @@ constructor(
|
||||||
if (changes.userLibrary && userLibrary != null) {
|
if (changes.userLibrary && userLibrary != null) {
|
||||||
val playlist = currentPlaylist.value
|
val playlist = currentPlaylist.value
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
logD("Updated playlist to ${currentPlaylist.value}")
|
|
||||||
_currentPlaylist.value =
|
_currentPlaylist.value =
|
||||||
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
||||||
|
logD("Updated playlist to ${currentPlaylist.value}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -233,8 +244,11 @@ constructor(
|
||||||
* @param uid The UID of the [Song] to load. Must be valid.
|
* @param uid The UID of the [Song] to load. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setSong(uid: Music.UID) {
|
fun setSong(uid: Music.UID) {
|
||||||
logD("Opening Song [uid: $uid]")
|
logD("Opening song $uid")
|
||||||
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
|
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
|
||||||
|
if (_currentSong.value == null) {
|
||||||
|
logW("Given song UID was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -244,9 +258,12 @@ constructor(
|
||||||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setAlbum(uid: Music.UID) {
|
fun setAlbum(uid: Music.UID) {
|
||||||
logD("Opening Album [uid: $uid]")
|
logD("Opening album $uid")
|
||||||
_currentAlbum.value =
|
_currentAlbum.value =
|
||||||
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
||||||
|
if (_currentAlbum.value == null) {
|
||||||
|
logW("Given album UID was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -256,9 +273,12 @@ constructor(
|
||||||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setArtist(uid: Music.UID) {
|
fun setArtist(uid: Music.UID) {
|
||||||
logD("Opening Artist [uid: $uid]")
|
logD("Opening artist $uid")
|
||||||
_currentArtist.value =
|
_currentArtist.value =
|
||||||
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
||||||
|
if (_currentArtist.value == null) {
|
||||||
|
logW("Given artist UID was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -268,9 +288,12 @@ constructor(
|
||||||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setGenre(uid: Music.UID) {
|
fun setGenre(uid: Music.UID) {
|
||||||
logD("Opening Genre [uid: $uid]")
|
logD("Opening genre $uid")
|
||||||
_currentGenre.value =
|
_currentGenre.value =
|
||||||
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
||||||
|
if (_currentGenre.value == null) {
|
||||||
|
logW("Given genre UID was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -280,9 +303,12 @@ constructor(
|
||||||
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
|
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setPlaylist(uid: Music.UID) {
|
fun setPlaylist(uid: Music.UID) {
|
||||||
logD("Opening Playlist [uid: $uid]")
|
logD("Opening playlist $uid")
|
||||||
_currentPlaylist.value =
|
_currentPlaylist.value =
|
||||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
||||||
|
if (_currentPlaylist.value == null) {
|
||||||
|
logW("Given playlist UID was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||||
|
@ -300,6 +326,7 @@ constructor(
|
||||||
fun savePlaylistEdit() {
|
fun savePlaylistEdit() {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
val editedPlaylist = _editedPlaylist.value ?: return
|
val editedPlaylist = _editedPlaylist.value ?: return
|
||||||
|
logD("Committing playlist edits")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
||||||
// TODO: The user could probably press some kind of button if they were fast enough.
|
// TODO: The user could probably press some kind of button if they were fast enough.
|
||||||
|
@ -320,6 +347,7 @@ constructor(
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
logD("Discarding playlist edits")
|
||||||
_editedPlaylist.value = null
|
_editedPlaylist.value = null
|
||||||
refreshPlaylistList(playlist)
|
refreshPlaylistList(playlist)
|
||||||
return true
|
return true
|
||||||
|
@ -341,6 +369,7 @@ constructor(
|
||||||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||||
_editedPlaylist.value = editedPlaylist
|
_editedPlaylist.value = editedPlaylist
|
||||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
||||||
|
@ -359,6 +388,7 @@ constructor(
|
||||||
if (realAt !in editedPlaylist.indices) {
|
if (realAt !in editedPlaylist.indices) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logD("Removing playlist song at $realAt [$at]")
|
||||||
editedPlaylist.removeAt(realAt)
|
editedPlaylist.removeAt(realAt)
|
||||||
_editedPlaylist.value = editedPlaylist
|
_editedPlaylist.value = editedPlaylist
|
||||||
refreshPlaylistList(
|
refreshPlaylistList(
|
||||||
|
@ -366,11 +396,13 @@ constructor(
|
||||||
if (editedPlaylist.isNotEmpty()) {
|
if (editedPlaylist.isNotEmpty()) {
|
||||||
UpdateInstructions.Remove(at, 1)
|
UpdateInstructions.Remove(at, 1)
|
||||||
} else {
|
} else {
|
||||||
|
logD("Playlist will be empty after removal, removing header")
|
||||||
UpdateInstructions.Remove(at - 2, 3)
|
UpdateInstructions.Remove(at - 2, 3)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAudioInfo(song: Song) {
|
private fun refreshAudioInfo(song: Song) {
|
||||||
|
logD("Refreshing audio info")
|
||||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||||
currentSongJob?.cancel()
|
currentSongJob?.cancel()
|
||||||
_songAudioProperties.value = null
|
_songAudioProperties.value = null
|
||||||
|
@ -378,6 +410,7 @@ constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val info = audioPropertiesFactory.extract(song)
|
val info = audioPropertiesFactory.extract(song)
|
||||||
yield()
|
yield()
|
||||||
|
logD("Updating audio info to $info")
|
||||||
_songAudioProperties.value = info
|
_songAudioProperties.value = info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -399,12 +432,11 @@ constructor(
|
||||||
// To create a good user experience regarding disc numbers, we group the album's
|
// To create a good user experience regarding disc numbers, we group the album's
|
||||||
// songs up by disc and then delimit the groups by a disc header.
|
// songs up by disc and then delimit the groups by a disc header.
|
||||||
val songs = albumSongSort.songs(album.songs)
|
val songs = albumSongSort.songs(album.songs)
|
||||||
// Songs without disc tags become part of Disc 1.
|
val byDisc = songs.groupBy { it.disc }
|
||||||
val byDisc = songs.groupBy { it.disc ?: Disc(1, null) }
|
|
||||||
if (byDisc.size > 1) {
|
if (byDisc.size > 1) {
|
||||||
logD("Album has more than one disc, interspersing headers")
|
logD("Album has more than one disc, interspersing headers")
|
||||||
for (entry in byDisc.entries) {
|
for (entry in byDisc.entries) {
|
||||||
list.add(entry.key)
|
list.add(DiscHeader(entry.key))
|
||||||
list.addAll(entry.value)
|
list.addAll(entry.value)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -412,6 +444,7 @@ constructor(
|
||||||
list.addAll(songs)
|
list.addAll(songs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logD("Update album list to ${list.size} items with $instructions")
|
||||||
_albumInstructions.put(instructions)
|
_albumInstructions.put(instructions)
|
||||||
_albumList.value = list
|
_albumList.value = list
|
||||||
}
|
}
|
||||||
|
@ -419,10 +452,9 @@ constructor(
|
||||||
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
||||||
logD("Refreshing artist list")
|
logD("Refreshing artist list")
|
||||||
val list = mutableListOf<Item>()
|
val list = mutableListOf<Item>()
|
||||||
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
|
|
||||||
|
|
||||||
val byReleaseGroup =
|
val grouping =
|
||||||
albums.groupBy {
|
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||||
// Remap the complicated ReleaseType data structure into an easier
|
// Remap the complicated ReleaseType data structure into an easier
|
||||||
// "AlbumGrouping" enum that will automatically group and sort
|
// "AlbumGrouping" enum that will automatically group and sort
|
||||||
// the artist's albums.
|
// the artist's albums.
|
||||||
|
@ -436,15 +468,25 @@ constructor(
|
||||||
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
||||||
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
||||||
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
||||||
is ReleaseType.Mix -> AlbumGrouping.MIXES
|
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
|
||||||
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logD("Release groups for this artist: ${byReleaseGroup.keys}")
|
if (artist.implicitAlbums.isNotEmpty()) {
|
||||||
|
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
|
||||||
|
// inherits list, we can cast upwards and save a copy by directly inserting the
|
||||||
|
// implicit album list into the mapping.
|
||||||
|
logD("Implicit albums present, adding to list")
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(grouping as MutableMap<AlbumGrouping, List<Album>>)[AlbumGrouping.APPEARANCES] =
|
||||||
|
artist.implicitAlbums
|
||||||
|
}
|
||||||
|
|
||||||
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
|
logD("Release groups for this artist: ${grouping.keys}")
|
||||||
|
|
||||||
|
for (entry in grouping.entries) {
|
||||||
val header = BasicHeader(entry.key.headerTitleRes)
|
val header = BasicHeader(entry.key.headerTitleRes)
|
||||||
list.add(Divider(header))
|
list.add(Divider(header))
|
||||||
list.add(header)
|
list.add(header)
|
||||||
|
@ -465,6 +507,7 @@ constructor(
|
||||||
list.addAll(artistSongSort.songs(artist.songs))
|
list.addAll(artistSongSort.songs(artist.songs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logD("Updating artist list to ${list.size} items with $instructions")
|
||||||
_artistInstructions.put(instructions)
|
_artistInstructions.put(instructions)
|
||||||
_artistList.value = list.toList()
|
_artistList.value = list.toList()
|
||||||
}
|
}
|
||||||
|
@ -483,12 +526,14 @@ constructor(
|
||||||
list.add(songHeader)
|
list.add(songHeader)
|
||||||
val instructions =
|
val instructions =
|
||||||
if (replace) {
|
if (replace) {
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
// Intentional so that the header item isn't replaced alongside the songs
|
||||||
UpdateInstructions.Replace(list.size)
|
UpdateInstructions.Replace(list.size)
|
||||||
} else {
|
} else {
|
||||||
UpdateInstructions.Diff
|
UpdateInstructions.Diff
|
||||||
}
|
}
|
||||||
list.addAll(genreSongSort.songs(genre.songs))
|
list.addAll(genreSongSort.songs(genre.songs))
|
||||||
|
|
||||||
|
logD("Updating genre list to ${list.size} items with $instructions")
|
||||||
_genreInstructions.put(instructions)
|
_genreInstructions.put(instructions)
|
||||||
_genreList.value = list
|
_genreList.value = list
|
||||||
}
|
}
|
||||||
|
@ -508,6 +553,7 @@ constructor(
|
||||||
list.addAll(songs)
|
list.addAll(songs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logD("Updating playlist list to ${list.size} items with $instructions")
|
||||||
_playlistInstructions.put(instructions)
|
_playlistInstructions.put(instructions)
|
||||||
_playlistList.value = list
|
_playlistList.value = list
|
||||||
}
|
}
|
||||||
|
@ -524,8 +570,9 @@ constructor(
|
||||||
SINGLES(R.string.lbl_singles),
|
SINGLES(R.string.lbl_singles),
|
||||||
COMPILATIONS(R.string.lbl_compilations),
|
COMPILATIONS(R.string.lbl_compilations),
|
||||||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
SOUNDTRACKS(R.string.lbl_soundtracks),
|
||||||
MIXES(R.string.lbl_mixes),
|
DJMIXES(R.string.lbl_mixes),
|
||||||
MIXTAPES(R.string.lbl_mixtapes),
|
MIXTAPES(R.string.lbl_mixtapes),
|
||||||
|
APPEARANCES(R.string.lbl_appears_on),
|
||||||
LIVE(R.string.lbl_live_group),
|
LIVE(R.string.lbl_live_group),
|
||||||
REMIXES(R.string.lbl_remix_group),
|
REMIXES(R.string.lbl_remix_group),
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,10 +41,24 @@ import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.collect
|
||||||
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows information for a particular [Genre].
|
* A [ListFragment] that shows information for a particular [Genre].
|
||||||
|
@ -146,7 +160,14 @@ class GenreDetailFragment :
|
||||||
musicModel.addToPlaylist(currentGenre)
|
musicModel.addToPlaylist(currentGenre)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
R.id.action_share -> {
|
||||||
|
requireContext().share(currentGenre)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -213,7 +234,7 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
private fun updatePlaylist(genre: Genre?) {
|
private fun updatePlaylist(genre: Genre?) {
|
||||||
if (genre == null) {
|
if (genre == null) {
|
||||||
// Genre we were showing no longer exists.
|
logD("No genre to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -222,15 +243,18 @@ class GenreDetailFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
var playingMusic: Music? = null
|
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
|
||||||
if (parent is Artist) {
|
val playingItem =
|
||||||
playingMusic = parent
|
when (parent) {
|
||||||
}
|
// Always highlight a playing artist if it's from this genre, and if the currently
|
||||||
// Prefer songs that might be playing from this genre.
|
// playing song is contained within.
|
||||||
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) {
|
is Artist -> parent.takeIf { song?.run { artists.contains(it) } ?: false }
|
||||||
playingMusic = song
|
// If the parent is the artist itself, use the currently playing song.
|
||||||
}
|
currentGenre -> song
|
||||||
genreListAdapter.setPlaying(playingMusic, isPlaying)
|
// Nothing is playing from this artist.
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
genreListAdapter.setPlaying(playingItem, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNavigation(item: Music?) {
|
private fun handleNavigation(item: Music?) {
|
||||||
|
|
|
@ -44,10 +44,24 @@ import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.collect
|
||||||
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows information for a particular [Playlist].
|
* A [ListFragment] that shows information for a particular [Playlist].
|
||||||
|
@ -197,11 +211,18 @@ class PlaylistDetailFragment :
|
||||||
musicModel.deletePlaylist(currentPlaylist)
|
musicModel.deletePlaylist(currentPlaylist)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
requireContext().share(currentPlaylist)
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_save -> {
|
R.id.action_save -> {
|
||||||
detailModel.savePlaylistEdit()
|
detailModel.savePlaylistEdit()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -238,19 +259,26 @@ class PlaylistDetailFragment :
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
|
binding.detailNormalToolbar.apply {
|
||||||
binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}"
|
title = playlist.name.resolve(requireContext())
|
||||||
|
// Disable options that make no sense with an empty playlist
|
||||||
|
val playable = playlist.songs.isNotEmpty()
|
||||||
|
if (!playable) {
|
||||||
|
logD("Playlist is empty, disabling playback/share options")
|
||||||
|
}
|
||||||
|
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_share).isEnabled = playable
|
||||||
|
}
|
||||||
|
binding.detailEditToolbar.title =
|
||||||
|
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
|
||||||
playlistHeaderAdapter.setParent(playlist)
|
playlistHeaderAdapter.setParent(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// Prefer songs that might be playing from this playlist.
|
// Prefer songs that are playing from this playlist.
|
||||||
if (parent is Playlist &&
|
playlistListAdapter.setPlaying(
|
||||||
parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) {
|
song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying)
|
||||||
playlistListAdapter.setPlaying(song, isPlaying)
|
|
||||||
} else {
|
|
||||||
playlistListAdapter.setPlaying(null, isPlaying)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNavigation(item: Music?) {
|
private fun handleNavigation(item: Music?) {
|
||||||
|
@ -287,6 +315,7 @@ class PlaylistDetailFragment :
|
||||||
selectionModel.drop()
|
selectionModel.drop()
|
||||||
|
|
||||||
if (editedPlaylist != null) {
|
if (editedPlaylist != null) {
|
||||||
|
logD("Updating save button state")
|
||||||
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
||||||
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
||||||
}
|
}
|
||||||
|
@ -308,9 +337,18 @@ class PlaylistDetailFragment :
|
||||||
private fun updateMultiToolbar() {
|
private fun updateMultiToolbar() {
|
||||||
val id =
|
val id =
|
||||||
when {
|
when {
|
||||||
detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar
|
detailModel.editedPlaylist.value != null -> {
|
||||||
selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar
|
logD("Currently editing playlist, showing edit toolbar")
|
||||||
else -> R.id.detail_normal_toolbar
|
R.id.detail_edit_toolbar
|
||||||
|
}
|
||||||
|
selectionModel.selected.value.isNotEmpty() -> {
|
||||||
|
logD("Currently selecting, showing selection toolbar")
|
||||||
|
R.id.detail_selection_toolbar
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logD("Using normal toolbar")
|
||||||
|
R.id.detail_normal_toolbar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requireBinding().detailToolbar.setVisible(id)
|
requireBinding().detailToolbar.setVisible(id)
|
||||||
|
|
|
@ -22,8 +22,8 @@ import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.appcompat.R
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [TextInputEditText] that deliberately restricts all input except for selection. This will work
|
* A [TextInputEditText] that deliberately restricts all input except for selection. This will work
|
||||||
|
|
|
@ -41,6 +41,7 @@ import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.concatLocalized
|
import org.oxycblt.auxio.util.concatLocalized
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewBindingDialogFragment] that shows information about a Song.
|
* A [ViewBindingDialogFragment] that shows information about a Song.
|
||||||
|
@ -73,7 +74,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||||
|
|
||||||
private fun updateSong(song: Song?, info: AudioProperties?) {
|
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||||
if (song == null) {
|
if (song == null) {
|
||||||
// Song we were showing no longer exists.
|
logD("No song to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -86,7 +87,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||||
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
|
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
|
||||||
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
|
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
|
||||||
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
|
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
|
||||||
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolveDate(context))) }
|
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
|
||||||
song.track?.let {
|
song.track?.let {
|
||||||
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
|
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [DetailHeaderAdapter] that shows [Artist] information.
|
* A [DetailHeaderAdapter] that shows [Artist] information.
|
||||||
|
@ -91,6 +92,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||||
// The artist does not have any songs, so hide functionality that makes no sense.
|
// The artist does not have any songs, so hide functionality that makes no sense.
|
||||||
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
||||||
// Artists are always guaranteed to have albums however, so continue to show those.
|
// Artists are always guaranteed to have albums however, so continue to show those.
|
||||||
|
logD("Artist is empty, disabling genres and playback")
|
||||||
binding.detailSubhead.isVisible = false
|
binding.detailSubhead.isVisible = false
|
||||||
binding.detailPlayButton.isEnabled = false
|
binding.detailPlayButton.isEnabled = false
|
||||||
binding.detailShuffleButton.isEnabled = false
|
binding.detailShuffleButton.isEnabled = false
|
||||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.detail.header
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
|
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
|
||||||
|
@ -47,6 +48,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
|
||||||
* @param parent The new [MusicParent] to show.
|
* @param parent The new [MusicParent] to show.
|
||||||
*/
|
*/
|
||||||
fun setParent(parent: T) {
|
fun setParent(parent: T) {
|
||||||
|
logD("Updating parent [old: $currentParent new: $parent]")
|
||||||
currentParent = parent
|
currentParent = parent
|
||||||
rebindParent()
|
rebindParent()
|
||||||
}
|
}
|
||||||
|
@ -55,6 +57,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
|
||||||
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
|
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
|
||||||
*/
|
*/
|
||||||
protected fun rebindParent() {
|
protected fun rebindParent() {
|
||||||
|
logD("Rebinding parent")
|
||||||
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [DetailHeaderAdapter] that shows [Playlist] information.
|
* A [DetailHeaderAdapter] that shows [Playlist] information.
|
||||||
|
@ -57,6 +58,7 @@ class PlaylistDetailHeaderAdapter(private val listener: Listener) :
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
|
||||||
editedPlaylist = songs
|
editedPlaylist = songs
|
||||||
rebindParent()
|
rebindParent()
|
||||||
}
|
}
|
||||||
|
@ -83,8 +85,16 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||||
editedPlaylist: List<Song>?,
|
editedPlaylist: List<Song>?,
|
||||||
listener: DetailHeaderAdapter.Listener
|
listener: DetailHeaderAdapter.Listener
|
||||||
) {
|
) {
|
||||||
// TODO: Debug perpetually re-binding images
|
if (editedPlaylist != null) {
|
||||||
binding.detailCover.bind(playlist, editedPlaylist)
|
logD("Binding edited playlist image")
|
||||||
|
binding.detailCover.bind(
|
||||||
|
editedPlaylist,
|
||||||
|
binding.context.getString(R.string.desc_playlist_image, playlist.name),
|
||||||
|
R.drawable.ic_playlist_24)
|
||||||
|
} else {
|
||||||
|
binding.detailCover.bind(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
||||||
binding.detailName.text = playlist.name.resolve(binding.context)
|
binding.detailName.text = playlist.name.resolve(binding.context)
|
||||||
// Nothing about a playlist is applicable to the sub-head text.
|
// Nothing about a playlist is applicable to the sub-head text.
|
||||||
|
@ -103,12 +113,17 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||||
binding.context.getString(R.string.def_song_count)
|
binding.context.getString(R.string.def_song_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||||
|
if (!playable) {
|
||||||
|
logD("Playlist is being edited or is empty, disabling playback options")
|
||||||
|
}
|
||||||
|
|
||||||
binding.detailPlayButton.apply {
|
binding.detailPlayButton.apply {
|
||||||
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
|
isEnabled = playable
|
||||||
setOnClickListener { listener.onPlay() }
|
setOnClickListener { listener.onPlay() }
|
||||||
}
|
}
|
||||||
binding.detailShuffleButton.apply {
|
binding.detailShuffleButton.apply {
|
||||||
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
|
isEnabled = playable
|
||||||
setOnClickListener { listener.onShuffle() }
|
setOnClickListener { listener.onShuffle() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
@ -37,6 +38,7 @@ import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||||
|
@ -49,14 +51,14 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
override fun getItemViewType(position: Int) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (getItem(position)) {
|
when (getItem(position)) {
|
||||||
// Support sub-headers for each disc, and special album songs.
|
// Support sub-headers for each disc, and special album songs.
|
||||||
is Disc -> DiscViewHolder.VIEW_TYPE
|
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
|
||||||
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
||||||
else -> super.getItemViewType(position)
|
else -> super.getItemViewType(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
|
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
||||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
||||||
else -> super.onCreateViewHolder(parent, viewType)
|
else -> super.onCreateViewHolder(parent, viewType)
|
||||||
}
|
}
|
||||||
|
@ -64,7 +66,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
super.onBindViewHolder(holder, position)
|
super.onBindViewHolder(holder, position)
|
||||||
when (val item = getItem(position)) {
|
when (val item = getItem(position)) {
|
||||||
is Disc -> (holder as DiscViewHolder).bind(item)
|
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
|
||||||
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
|
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,7 +78,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
||||||
when {
|
when {
|
||||||
oldItem is Disc && newItem is Disc ->
|
oldItem is Disc && newItem is Disc ->
|
||||||
DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
oldItem is Song && newItem is Song ->
|
oldItem is Song && newItem is Song ->
|
||||||
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
|
|
||||||
|
@ -88,23 +90,37 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
|
* A wrapper around [Disc] signifying that a header should be shown for a disc group.
|
||||||
* to create an instance.
|
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
|
data class DiscHeader(val inner: Disc?) : Item
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
|
||||||
|
* [from] to create an instance.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
/**
|
/**
|
||||||
* Bind new data to this instance.
|
* Bind new data to this instance.
|
||||||
*
|
*
|
||||||
* @param disc The new [disc] to bind.
|
* @param discHeader The new [DiscHeader] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(disc: Disc) {
|
fun bind(discHeader: DiscHeader) {
|
||||||
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
|
val disc = discHeader.inner
|
||||||
binding.discName.apply {
|
if (disc != null) {
|
||||||
text = disc.name
|
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
|
||||||
isGone = disc.name == null
|
binding.discName.apply {
|
||||||
|
text = disc.name
|
||||||
|
isGone = text == null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logD("Disc is null, defaulting to no disc")
|
||||||
|
binding.discNumber.text = binding.context.getString(R.string.def_disc)
|
||||||
|
binding.discName.isGone = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,7 +135,7 @@ private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun from(parent: View) =
|
fun from(parent: View) =
|
||||||
DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
|
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
|
@ -147,31 +163,33 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
|
||||||
fun bind(song: Song, listener: SelectableListListener<Song>) {
|
fun bind(song: Song, listener: SelectableListListener<Song>) {
|
||||||
listener.bind(song, this, menuButton = binding.songMenu)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
|
|
||||||
binding.songTrack.apply {
|
val track = song.track
|
||||||
if (song.track != null) {
|
if (track != null) {
|
||||||
// Instead of an album cover, we show the track number, as the song list
|
binding.songTrackCover.contentDescription =
|
||||||
// within the album detail view would have homogeneous album covers otherwise.
|
binding.context.getString(R.string.desc_track_number, track)
|
||||||
|
binding.songTrackText.apply {
|
||||||
|
isVisible = true
|
||||||
text = context.getString(R.string.fmt_number, song.track)
|
text = context.getString(R.string.fmt_number, song.track)
|
||||||
isInvisible = false
|
|
||||||
contentDescription = context.getString(R.string.desc_track_number, song.track)
|
|
||||||
} else {
|
|
||||||
// No track, do not show a number, instead showing a generic icon.
|
|
||||||
text = ""
|
|
||||||
isInvisible = true
|
|
||||||
contentDescription = context.getString(R.string.def_track)
|
|
||||||
}
|
}
|
||||||
|
binding.songTrackPlaceholder.isInvisible = true
|
||||||
|
} else {
|
||||||
|
binding.songTrackCover.contentDescription =
|
||||||
|
binding.context.getString(R.string.def_track)
|
||||||
|
binding.songTrackText.apply {
|
||||||
|
isInvisible = true
|
||||||
|
text = null
|
||||||
|
}
|
||||||
|
binding.songTrackPlaceholder.isVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.songName.text = song.name.resolve(binding.context)
|
binding.songName.text = song.name.resolve(binding.context)
|
||||||
|
// Use duration instead of album or artist for each song to be more contextually relevant.
|
||||||
// Use duration instead of album or artist for each song, as this text would
|
|
||||||
// be homogenous otherwise.
|
|
||||||
binding.songDuration.text = song.durationMs.formatDurationMs(false)
|
binding.songDuration.text = song.durationMs.formatDurationMs(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.songTrackBg.isPlaying = isPlaying
|
binding.songTrackCover.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
|
|
@ -29,7 +29,10 @@ import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
|
||||||
|
@ -107,7 +110,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.parentImage.isPlaying = isPlaying
|
binding.parentImage.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
@ -159,7 +162,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.songAlbumCover.isPlaying = isPlaying
|
binding.songAlbumCover.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
|
|
@ -31,8 +31,10 @@ import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.*
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.*
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
|
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
|
||||||
|
import org.oxycblt.auxio.list.recycler.DividerViewHolder
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
|
|
@ -27,6 +27,7 @@ import androidx.appcompat.widget.TooltipCompat
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.R as MR
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
@ -47,6 +48,7 @@ import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.getDimen
|
import org.oxycblt.auxio.util.getDimen
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
|
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
|
||||||
|
@ -97,6 +99,7 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logD("Updating editing state [old: $isEditing new: $editing]")
|
||||||
this.isEditing = editing
|
this.isEditing = editing
|
||||||
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
|
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
|
||||||
}
|
}
|
||||||
|
@ -213,7 +216,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
|
||||||
override val delete = binding.background
|
override val delete = binding.background
|
||||||
override val background =
|
override val background =
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
||||||
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
|
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
|
||||||
elevation = binding.context.getDimen(R.dimen.elevation_normal)
|
elevation = binding.context.getDimen(R.dimen.elevation_normal)
|
||||||
alpha = 0
|
alpha = 0
|
||||||
}
|
}
|
||||||
|
@ -223,7 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
|
||||||
LayerDrawable(
|
LayerDrawable(
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
|
||||||
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
|
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
|
||||||
},
|
},
|
||||||
background))
|
background))
|
||||||
}
|
}
|
||||||
|
@ -253,7 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.interactBody.isSelected = isActive
|
binding.interactBody.isSelected = isActive
|
||||||
binding.songAlbumCover.isPlaying = isPlaying
|
binding.songAlbumCover.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEditing(editing: Boolean) {
|
override fun updateEditing(editing: Boolean) {
|
||||||
|
|
|
@ -24,7 +24,8 @@ import androidx.annotation.StringRes
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
|
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.adapter.*
|
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||||
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
|
|
@ -22,8 +22,8 @@ import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import com.google.android.material.R
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,23 +44,32 @@ constructor(
|
||||||
|
|
||||||
override fun show() {
|
override fun show() {
|
||||||
// Will already show eventually, need to do nothing.
|
// Will already show eventually, need to do nothing.
|
||||||
if (flipping) return
|
if (flipping) {
|
||||||
|
logD("Already flipping, aborting show")
|
||||||
|
return
|
||||||
|
}
|
||||||
// Apply the new configuration possibly set in flipTo. This should occur even if
|
// Apply the new configuration possibly set in flipTo. This should occur even if
|
||||||
// a flip was canceled by a hide.
|
// a flip was canceled by a hide.
|
||||||
pendingConfig?.run {
|
pendingConfig?.run {
|
||||||
|
this@FlipFloatingActionButton.logD("Applying pending configuration")
|
||||||
setImageResource(iconRes)
|
setImageResource(iconRes)
|
||||||
contentDescription = context.getString(contentDescriptionRes)
|
contentDescription = context.getString(contentDescriptionRes)
|
||||||
setOnClickListener(clickListener)
|
setOnClickListener(clickListener)
|
||||||
}
|
}
|
||||||
pendingConfig = null
|
pendingConfig = null
|
||||||
|
logD("Beginning show")
|
||||||
super.show()
|
super.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hide() {
|
override fun hide() {
|
||||||
|
if (flipping) {
|
||||||
|
logD("Hide was called, aborting flip")
|
||||||
|
}
|
||||||
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
|
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
|
||||||
flipping = false
|
flipping = false
|
||||||
// Don't pass any kind of listener so that future flip operations will not be able
|
// Don't pass any kind of listener so that future flip operations will not be able
|
||||||
// to show the FAB again.
|
// to show the FAB again.
|
||||||
|
logD("Beginning hide")
|
||||||
super.hide()
|
super.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,9 +91,12 @@ constructor(
|
||||||
|
|
||||||
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
|
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
|
||||||
if (!isOrWillBeHidden) {
|
if (!isOrWillBeHidden) {
|
||||||
|
logD("Starting hide for flip")
|
||||||
flipping = true
|
flipping = true
|
||||||
// We will re-show the FAB later, assuming that there was not a prior flip operation.
|
// We will re-show the FAB later, assuming that there was not a prior flip operation.
|
||||||
super.hide(FlipVisibilityListener())
|
super.hide(FlipVisibilityListener())
|
||||||
|
} else {
|
||||||
|
logD("Already hiding, will apply config later")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,7 +109,7 @@ constructor(
|
||||||
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
|
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
|
||||||
override fun onHidden(fab: FloatingActionButton) {
|
override fun onHidden(fab: FloatingActionButton) {
|
||||||
if (!flipping) return
|
if (!flipping) return
|
||||||
logD("Showing for a flip operation")
|
logD("Starting show for flip")
|
||||||
flipping = false
|
flipping = false
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,16 +46,39 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.MainFragmentDirections
|
import org.oxycblt.auxio.MainFragmentDirections
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||||
import org.oxycblt.auxio.home.list.*
|
import org.oxycblt.auxio.home.list.AlbumListFragment
|
||||||
|
import org.oxycblt.auxio.home.list.ArtistListFragment
|
||||||
|
import org.oxycblt.auxio.home.list.GenreListFragment
|
||||||
|
import org.oxycblt.auxio.home.list.PlaylistListFragment
|
||||||
|
import org.oxycblt.auxio.home.list.SongListFragment
|
||||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.IndexingProgress
|
||||||
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.NoAudioPermissionException
|
||||||
|
import org.oxycblt.auxio.music.NoMusicException
|
||||||
|
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.MainNavigationAction
|
import org.oxycblt.auxio.navigation.MainNavigationAction
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.collect
|
||||||
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.getColorCompat
|
||||||
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||||
|
@ -188,54 +211,65 @@ class HomeFragment :
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
when (item.itemId) {
|
return when (item.itemId) {
|
||||||
// Handle main actions (Search, Settings, About)
|
// Handle main actions (Search, Settings, About)
|
||||||
R.id.action_search -> {
|
R.id.action_search -> {
|
||||||
logD("Navigating to search")
|
logD("Navigating to search")
|
||||||
setupAxisTransitions(MaterialSharedAxis.Z)
|
setupAxisTransitions(MaterialSharedAxis.Z)
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch())
|
findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch())
|
||||||
|
true
|
||||||
}
|
}
|
||||||
R.id.action_settings -> {
|
R.id.action_settings -> {
|
||||||
logD("Navigating to settings")
|
logD("Navigating to settings")
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
|
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
|
||||||
|
true
|
||||||
}
|
}
|
||||||
R.id.action_about -> {
|
R.id.action_about -> {
|
||||||
logD("Navigating to about")
|
logD("Navigating to about")
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
|
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sort menu
|
// Handle sort menu
|
||||||
R.id.submenu_sorting -> {
|
R.id.submenu_sorting -> {
|
||||||
// Junk click event when opening the menu
|
// Junk click event when opening the menu
|
||||||
|
true
|
||||||
}
|
}
|
||||||
R.id.option_sort_asc -> {
|
R.id.option_sort_asc -> {
|
||||||
|
logD("Switching to ascending sorting")
|
||||||
item.isChecked = true
|
item.isChecked = true
|
||||||
homeModel.setSortForCurrentTab(
|
homeModel.setSortForCurrentTab(
|
||||||
homeModel
|
homeModel
|
||||||
.getSortForTab(homeModel.currentTabMode.value)
|
.getSortForTab(homeModel.currentTabMode.value)
|
||||||
.withDirection(Sort.Direction.ASCENDING))
|
.withDirection(Sort.Direction.ASCENDING))
|
||||||
|
true
|
||||||
}
|
}
|
||||||
R.id.option_sort_dec -> {
|
R.id.option_sort_dec -> {
|
||||||
|
logD("Switching to descending sorting")
|
||||||
item.isChecked = true
|
item.isChecked = true
|
||||||
homeModel.setSortForCurrentTab(
|
homeModel.setSortForCurrentTab(
|
||||||
homeModel
|
homeModel
|
||||||
.getSortForTab(homeModel.currentTabMode.value)
|
.getSortForTab(homeModel.currentTabMode.value)
|
||||||
.withDirection(Sort.Direction.DESCENDING))
|
.withDirection(Sort.Direction.DESCENDING))
|
||||||
|
true
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Sorting option was selected, mark it as selected and update the mode
|
val newMode = Sort.Mode.fromItemId(item.itemId)
|
||||||
item.isChecked = true
|
if (newMode != null) {
|
||||||
homeModel.setSortForCurrentTab(
|
// Sorting option was selected, mark it as selected and update the mode
|
||||||
homeModel
|
logD("Updating sort mode")
|
||||||
.getSortForTab(homeModel.currentTabMode.value)
|
item.isChecked = true
|
||||||
.withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId))))
|
homeModel.setSortForCurrentTab(
|
||||||
|
homeModel.getSortForTab(homeModel.currentTabMode.value).withMode(newMode))
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always handling it one way or another, so always return true
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupPager(binding: FragmentHomeBinding) {
|
private fun setupPager(binding: FragmentHomeBinding) {
|
||||||
|
@ -246,6 +280,7 @@ class HomeFragment :
|
||||||
if (homeModel.currentTabModes.size == 1) {
|
if (homeModel.currentTabModes.size == 1) {
|
||||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||||
// behavior.
|
// behavior.
|
||||||
|
logD("Single tab shown, disabling TabLayout")
|
||||||
binding.homeTabs.isVisible = false
|
binding.homeTabs.isVisible = false
|
||||||
binding.homeAppbar.setExpanded(true, false)
|
binding.homeAppbar.setExpanded(true, false)
|
||||||
toolbarParams.scrollFlags = 0
|
toolbarParams.scrollFlags = 0
|
||||||
|
@ -270,17 +305,26 @@ class HomeFragment :
|
||||||
val isVisible: (Int) -> Boolean =
|
val isVisible: (Int) -> Boolean =
|
||||||
when (tabMode) {
|
when (tabMode) {
|
||||||
// Disallow sorting by count for songs
|
// Disallow sorting by count for songs
|
||||||
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
|
MusicMode.SONGS -> {
|
||||||
|
logD("Using song-specific menu options")
|
||||||
|
({ id -> id != R.id.option_sort_count })
|
||||||
|
}
|
||||||
// Disallow sorting by album for albums
|
// Disallow sorting by album for albums
|
||||||
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
|
MusicMode.ALBUMS -> {
|
||||||
|
logD("Using album-specific menu options")
|
||||||
|
({ id -> id != R.id.option_sort_album })
|
||||||
|
}
|
||||||
// Only allow sorting by name, count, and duration for parents
|
// Only allow sorting by name, count, and duration for parents
|
||||||
else -> { id ->
|
else -> {
|
||||||
|
logD("Using parent-specific menu options")
|
||||||
|
({ id ->
|
||||||
id == R.id.option_sort_asc ||
|
id == R.id.option_sort_asc ||
|
||||||
id == R.id.option_sort_dec ||
|
id == R.id.option_sort_dec ||
|
||||||
id == R.id.option_sort_name ||
|
id == R.id.option_sort_name ||
|
||||||
id == R.id.option_sort_count ||
|
id == R.id.option_sort_count ||
|
||||||
id == R.id.option_sort_duration
|
id == R.id.option_sort_duration
|
||||||
}
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortMenu =
|
val sortMenu =
|
||||||
|
@ -288,18 +332,29 @@ class HomeFragment :
|
||||||
val toHighlight = homeModel.getSortForTab(tabMode)
|
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||||
|
|
||||||
for (option in sortMenu) {
|
for (option in sortMenu) {
|
||||||
// Check the ascending option and corresponding sort option to align with
|
val isCurrentMode = option.itemId == toHighlight.mode.itemId
|
||||||
|
val isCurrentlyAscending =
|
||||||
|
option.itemId == R.id.option_sort_asc &&
|
||||||
|
toHighlight.direction == Sort.Direction.ASCENDING
|
||||||
|
val isCurrentlyDescending =
|
||||||
|
option.itemId == R.id.option_sort_dec &&
|
||||||
|
toHighlight.direction == Sort.Direction.DESCENDING
|
||||||
|
// Check the corresponding direction and mode sort options to align with
|
||||||
// the current sort of the tab.
|
// the current sort of the tab.
|
||||||
if (option.itemId == toHighlight.mode.itemId ||
|
if (isCurrentMode || isCurrentlyAscending || isCurrentlyDescending) {
|
||||||
(option.itemId == R.id.option_sort_asc &&
|
logD(
|
||||||
toHighlight.direction == Sort.Direction.ASCENDING) ||
|
"Checking $option option [mode: $isCurrentMode asc: $isCurrentlyAscending dec: $isCurrentlyDescending]")
|
||||||
(option.itemId == R.id.option_sort_dec &&
|
// Note: We cannot inline this boolean assignment since it unchecks all other radio
|
||||||
toHighlight.direction == Sort.Direction.DESCENDING)) {
|
// buttons (even when setting it to false), which would result in nothing being
|
||||||
|
// selected.
|
||||||
option.isChecked = true
|
option.isChecked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable options that are not allowed by the isVisible lambda
|
// Disable options that are not allowed by the isVisible lambda
|
||||||
option.isVisible = isVisible(option.itemId)
|
option.isVisible = isVisible(option.itemId)
|
||||||
|
if (!option.isVisible) {
|
||||||
|
logD("Hiding $option option")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
// Update the scrolling view in AppBarLayout to align with the current tab's
|
||||||
|
@ -315,10 +370,12 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabMode != MusicMode.PLAYLISTS) {
|
if (tabMode != MusicMode.PLAYLISTS) {
|
||||||
|
logD("Flipping to shuffle button")
|
||||||
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
||||||
playbackModel.shuffleAll()
|
playbackModel.shuffleAll()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logD("Flipping to playlist button")
|
||||||
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
||||||
musicModel.createPlaylist()
|
musicModel.createPlaylist()
|
||||||
}
|
}
|
||||||
|
@ -328,6 +385,7 @@ class HomeFragment :
|
||||||
private fun handleRecreate(recreate: Unit?) {
|
private fun handleRecreate(recreate: Unit?) {
|
||||||
if (recreate == null) return
|
if (recreate == null) return
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
|
logD("Recreating ViewPager")
|
||||||
// Move back to position zero, as there must be a tab there.
|
// Move back to position zero, as there must be a tab there.
|
||||||
binding.homePager.currentItem = 0
|
binding.homePager.currentItem = 0
|
||||||
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
||||||
|
@ -364,7 +422,7 @@ class HomeFragment :
|
||||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||||
when (error) {
|
when (error) {
|
||||||
is NoAudioPermissionException -> {
|
is NoAudioPermissionException -> {
|
||||||
logD("Updating UI to permission request state")
|
logD("Showing permission prompt")
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||||
// Configure the action to act as a permission launcher.
|
// Configure the action to act as a permission launcher.
|
||||||
binding.homeIndexingAction.apply {
|
binding.homeIndexingAction.apply {
|
||||||
|
@ -379,7 +437,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is NoMusicException -> {
|
is NoMusicException -> {
|
||||||
logD("Updating UI to no music state")
|
logD("Showing no music error")
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||||
// Configure the action to act as a reload trigger.
|
// Configure the action to act as a reload trigger.
|
||||||
binding.homeIndexingAction.apply {
|
binding.homeIndexingAction.apply {
|
||||||
|
@ -389,7 +447,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logD("Updating UI to error state")
|
logD("Showing generic error")
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||||
// Configure the action to act as a reload trigger.
|
// Configure the action to act as a reload trigger.
|
||||||
binding.homeIndexingAction.apply {
|
binding.homeIndexingAction.apply {
|
||||||
|
@ -432,8 +490,10 @@ class HomeFragment :
|
||||||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
||||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
||||||
if (songs.isEmpty() || isFastScrolling) {
|
if (songs.isEmpty() || isFastScrolling) {
|
||||||
|
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
||||||
binding.homeFab.hide()
|
binding.homeFab.hide()
|
||||||
} else {
|
} else {
|
||||||
|
logD("Showing fab")
|
||||||
binding.homeFab.show()
|
binding.homeFab.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.home.tabs.Tab
|
import org.oxycblt.auxio.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -67,15 +68,18 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
||||||
|
|
||||||
override fun migrate() {
|
override fun migrate() {
|
||||||
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
||||||
|
logD("Migrating tab setting")
|
||||||
val oldTabs =
|
val oldTabs =
|
||||||
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||||
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
||||||
|
logD("Old tabs: $oldTabs")
|
||||||
|
|
||||||
// The playlist tab is now parsed, but it needs to be made visible.
|
// The playlist tab is now parsed, but it needs to be made visible.
|
||||||
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
|
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
|
||||||
if (playlistIndex > -1) { // Sanity check
|
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
||||||
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
|
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
|
||||||
}
|
logD("New tabs: $oldTabs")
|
||||||
|
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
|
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
|
||||||
remove(OLD_KEY_LIB_TABS)
|
remove(OLD_KEY_LIB_TABS)
|
||||||
|
@ -85,8 +89,14 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
||||||
|
|
||||||
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
||||||
when (key) {
|
when (key) {
|
||||||
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
|
getString(R.string.set_key_home_tabs) -> {
|
||||||
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
|
logD("Dispatching tab setting change")
|
||||||
|
listener.onTabsChanged()
|
||||||
|
}
|
||||||
|
getString(R.string.set_key_hide_collaborators) -> {
|
||||||
|
logD("Dispatching collaborator setting change")
|
||||||
|
listener.onHideCollaboratorsChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.home.tabs.Tab
|
import org.oxycblt.auxio.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
@ -68,8 +75,7 @@ constructor(
|
||||||
private val _artistsList = MutableStateFlow(listOf<Artist>())
|
private val _artistsList = MutableStateFlow(listOf<Artist>())
|
||||||
/**
|
/**
|
||||||
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that
|
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that
|
||||||
* if "Hide collaborators" is on, this list will not include [Artist]s where
|
* if "Hide collaborators" is on, this list will not include collaborator [Artist]s.
|
||||||
* [Artist.isCollaborator] is true.
|
|
||||||
*/
|
*/
|
||||||
val artistsList: MutableStateFlow<List<Artist>>
|
val artistsList: MutableStateFlow<List<Artist>>
|
||||||
get() = _artistsList
|
get() = _artistsList
|
||||||
|
@ -137,7 +143,6 @@ constructor(
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
val deviceLibrary = musicRepository.deviceLibrary
|
||||||
logD(changes.deviceLibrary)
|
|
||||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||||
logD("Refreshing library")
|
logD("Refreshing library")
|
||||||
// Get the each list of items in the library to use as our list data.
|
// Get the each list of items in the library to use as our list data.
|
||||||
|
@ -150,9 +155,11 @@ constructor(
|
||||||
_artistsList.value =
|
_artistsList.value =
|
||||||
musicSettings.artistSort.artists(
|
musicSettings.artistSort.artists(
|
||||||
if (homeSettings.shouldHideCollaborators) {
|
if (homeSettings.shouldHideCollaborators) {
|
||||||
|
logD("Filtering collaborator artists")
|
||||||
// Hide Collaborators is enabled, filter out collaborators.
|
// Hide Collaborators is enabled, filter out collaborators.
|
||||||
deviceLibrary.artists.filter { !it.isCollaborator }
|
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
|
||||||
} else {
|
} else {
|
||||||
|
logD("Using all artists")
|
||||||
deviceLibrary.artists
|
deviceLibrary.artists
|
||||||
})
|
})
|
||||||
_genresInstructions.put(UpdateInstructions.Diff)
|
_genresInstructions.put(UpdateInstructions.Diff)
|
||||||
|
@ -170,12 +177,14 @@ constructor(
|
||||||
override fun onTabsChanged() {
|
override fun onTabsChanged() {
|
||||||
// Tabs changed, update the current tabs and set up a re-create event.
|
// Tabs changed, update the current tabs and set up a re-create event.
|
||||||
currentTabModes = makeTabModes()
|
currentTabModes = makeTabModes()
|
||||||
|
logD("Updating tabs: ${currentTabMode.value}")
|
||||||
_shouldRecreate.put(Unit)
|
_shouldRecreate.put(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onHideCollaboratorsChanged() {
|
override fun onHideCollaboratorsChanged() {
|
||||||
// Changes in the hide collaborator setting will change the artist contents
|
// Changes in the hide collaborator setting will change the artist contents
|
||||||
// of the library, consider it a library update.
|
// of the library, consider it a library update.
|
||||||
|
logD("Collaborator setting changed, forwarding update")
|
||||||
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -200,30 +209,34 @@ constructor(
|
||||||
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
|
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
|
||||||
*/
|
*/
|
||||||
fun setSortForCurrentTab(sort: Sort) {
|
fun setSortForCurrentTab(sort: Sort) {
|
||||||
logD("Updating ${_currentTabMode.value} sort to $sort")
|
|
||||||
// Can simply re-sort the current list of items without having to access the library.
|
// Can simply re-sort the current list of items without having to access the library.
|
||||||
when (_currentTabMode.value) {
|
when (val mode = _currentTabMode.value) {
|
||||||
MusicMode.SONGS -> {
|
MusicMode.SONGS -> {
|
||||||
|
logD("Updating song [$mode] sort mode to $sort")
|
||||||
musicSettings.songSort = sort
|
musicSettings.songSort = sort
|
||||||
_songsInstructions.put(UpdateInstructions.Replace(0))
|
_songsInstructions.put(UpdateInstructions.Replace(0))
|
||||||
_songsList.value = sort.songs(_songsList.value)
|
_songsList.value = sort.songs(_songsList.value)
|
||||||
}
|
}
|
||||||
MusicMode.ALBUMS -> {
|
MusicMode.ALBUMS -> {
|
||||||
|
logD("Updating album [$mode] sort mode to $sort")
|
||||||
musicSettings.albumSort = sort
|
musicSettings.albumSort = sort
|
||||||
_albumsInstructions.put(UpdateInstructions.Replace(0))
|
_albumsInstructions.put(UpdateInstructions.Replace(0))
|
||||||
_albumsLists.value = sort.albums(_albumsLists.value)
|
_albumsLists.value = sort.albums(_albumsLists.value)
|
||||||
}
|
}
|
||||||
MusicMode.ARTISTS -> {
|
MusicMode.ARTISTS -> {
|
||||||
|
logD("Updating artist [$mode] sort mode to $sort")
|
||||||
musicSettings.artistSort = sort
|
musicSettings.artistSort = sort
|
||||||
_artistsInstructions.put(UpdateInstructions.Replace(0))
|
_artistsInstructions.put(UpdateInstructions.Replace(0))
|
||||||
_artistsList.value = sort.artists(_artistsList.value)
|
_artistsList.value = sort.artists(_artistsList.value)
|
||||||
}
|
}
|
||||||
MusicMode.GENRES -> {
|
MusicMode.GENRES -> {
|
||||||
|
logD("Updating genre [$mode] sort mode to $sort")
|
||||||
musicSettings.genreSort = sort
|
musicSettings.genreSort = sort
|
||||||
_genresInstructions.put(UpdateInstructions.Replace(0))
|
_genresInstructions.put(UpdateInstructions.Replace(0))
|
||||||
_genresList.value = sort.genres(_genresList.value)
|
_genresList.value = sort.genres(_genresList.value)
|
||||||
}
|
}
|
||||||
MusicMode.PLAYLISTS -> {
|
MusicMode.PLAYLISTS -> {
|
||||||
|
logD("Updating playlist [$mode] sort mode to $sort")
|
||||||
musicSettings.playlistSort = sort
|
musicSettings.playlistSort = sort
|
||||||
_playlistsInstructions.put(UpdateInstructions.Replace(0))
|
_playlistsInstructions.put(UpdateInstructions.Replace(0))
|
||||||
_playlistsList.value = sort.playlists(_playlistsList.value)
|
_playlistsList.value = sort.playlists(_playlistsList.value)
|
||||||
|
|
|
@ -33,6 +33,7 @@ import android.text.TextUtils
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import androidx.core.widget.TextViewCompat
|
import androidx.core.widget.TextViewCompat
|
||||||
|
import com.google.android.material.R as MR
|
||||||
import com.google.android.material.textview.MaterialTextView
|
import com.google.android.material.textview.MaterialTextView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
|
@ -53,7 +54,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
||||||
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
|
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
|
||||||
|
|
||||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
|
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
|
||||||
setTextColor(context.getAttrColorCompat(R.attr.colorOnSecondary))
|
setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
|
||||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||||
gravity = Gravity.CENTER
|
gravity = Gravity.CENTER
|
||||||
includeFontPadding = false
|
includeFontPadding = false
|
||||||
|
@ -67,7 +68,10 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
||||||
private val paint: Paint =
|
private val paint: Paint =
|
||||||
Paint().apply {
|
Paint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
color = context.getAttrColorCompat(R.attr.colorSecondary).defaultColor
|
color =
|
||||||
|
context
|
||||||
|
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
|
||||||
|
.defaultColor
|
||||||
style = Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,12 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
|
import org.oxycblt.auxio.util.getDrawableCompat
|
||||||
|
import org.oxycblt.auxio.util.getInteger
|
||||||
|
import org.oxycblt.auxio.util.isRtl
|
||||||
|
import org.oxycblt.auxio.util.isUnder
|
||||||
|
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
|
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
|
||||||
|
|
|
@ -30,13 +30,18 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
|
@ -78,7 +83,8 @@ class AlbumListFragment :
|
||||||
|
|
||||||
collectImmediately(homeModel.albumsList, ::updateAlbums)
|
collectImmediately(homeModel.albumsList, ::updateAlbums)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(
|
||||||
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||||
|
@ -101,7 +107,7 @@ class AlbumListFragment :
|
||||||
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
|
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
|
||||||
|
|
||||||
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||||
|
@ -147,9 +153,11 @@ class AlbumListFragment :
|
||||||
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If an album is playing, highlight it within this adapter.
|
// Only highlight the album if it is currently playing, and if the currently
|
||||||
albumAdapter.setPlaying(parent as? Album, isPlaying)
|
// playing song is also contained within.
|
||||||
|
val album = (parent as? Album)?.takeIf { song?.album == it }
|
||||||
|
albumAdapter.setPlaying(album, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -28,8 +28,8 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||||
|
@ -39,11 +39,11 @@ import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -78,7 +78,8 @@ class ArtistListFragment :
|
||||||
|
|
||||||
collectImmediately(homeModel.artistsList, ::updateArtists)
|
collectImmediately(homeModel.artistsList, ::updateArtists)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(
|
||||||
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||||
|
@ -121,16 +122,18 @@ class ArtistListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateArtists(artists: List<Artist>) {
|
private fun updateArtists(artists: List<Artist>) {
|
||||||
artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) })
|
artistAdapter.update(artists, homeModel.artistsInstructions.consume())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSelection(selection: List<Music>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If an artist is playing, highlight it within this adapter.
|
// Only highlight the artist if it is currently playing, and if the currently
|
||||||
artistAdapter.setPlaying(parent as? Artist, isPlaying)
|
// playing song is also contained within.
|
||||||
|
val artist = (parent as? Artist)?.takeIf { song?.run { artists.contains(it) } ?: false }
|
||||||
|
artistAdapter.setPlaying(artist, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -28,8 +28,8 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.GenreViewHolder
|
import org.oxycblt.auxio.list.recycler.GenreViewHolder
|
||||||
|
@ -39,11 +39,11 @@ import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows a list of [Genre]s.
|
* A [ListFragment] that shows a list of [Genre]s.
|
||||||
|
@ -77,7 +77,8 @@ class GenreListFragment :
|
||||||
|
|
||||||
collectImmediately(homeModel.genresList, ::updateGenres)
|
collectImmediately(homeModel.genresList, ::updateGenres)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(
|
||||||
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||||
|
@ -120,16 +121,18 @@ class GenreListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateGenres(genres: List<Genre>) {
|
private fun updateGenres(genres: List<Genre>) {
|
||||||
genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) })
|
genreAdapter.update(genres, homeModel.genresInstructions.consume())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSelection(selection: List<Music>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If a genre is playing, highlight it within this adapter.
|
// Only highlight the genre if it is currently playing, and if the currently
|
||||||
genreAdapter.setPlaying(parent as? Genre, isPlaying)
|
// playing song is also contained within.
|
||||||
|
val genre = (parent as? Genre)?.takeIf { song?.run { genres.contains(it) } ?: false }
|
||||||
|
genreAdapter.setPlaying(genre, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -27,8 +27,8 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
|
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
|
||||||
|
@ -38,18 +38,16 @@ import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows a list of [Playlist]s.
|
* A [ListFragment] that shows a list of [Playlist]s.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
|
||||||
* TODO: Show a placeholder when there are no playlists.
|
|
||||||
*/
|
*/
|
||||||
class PlaylistListFragment :
|
class PlaylistListFragment :
|
||||||
ListFragment<Playlist, FragmentHomeListBinding>(),
|
ListFragment<Playlist, FragmentHomeListBinding>(),
|
||||||
|
@ -77,7 +75,8 @@ class PlaylistListFragment :
|
||||||
|
|
||||||
collectImmediately(homeModel.playlistsList, ::updatePlaylists)
|
collectImmediately(homeModel.playlistsList, ::updatePlaylists)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(
|
||||||
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||||
|
@ -120,17 +119,18 @@ class PlaylistListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlaylists(playlists: List<Playlist>) {
|
private fun updatePlaylists(playlists: List<Playlist>) {
|
||||||
playlistAdapter.update(
|
playlistAdapter.update(playlists, homeModel.playlistsInstructions.consume())
|
||||||
playlists, homeModel.playlistsInstructions.consume().also { logD(it) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSelection(selection: List<Music>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If a playlist is playing, highlight it within this adapter.
|
// Only highlight the playlist if it is currently playing, and if the currently
|
||||||
playlistAdapter.setPlaying(parent as? Playlist, isPlaying)
|
// playing song is also contained within.
|
||||||
|
val playlist = (parent as? Playlist)?.takeIf { it.songs.contains(song) }
|
||||||
|
playlistAdapter.setPlaying(playlist, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -30,8 +30,8 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
|
@ -155,12 +155,8 @@ class SongListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
if (parent == null) {
|
// Only indicate playback that is from all songs
|
||||||
songAdapter.setPlaying(song, isPlaying)
|
songAdapter.setPlaying(song.takeIf { parent == null }, isPlaying)
|
||||||
} else {
|
|
||||||
// Ignore playback that is not from all songs
|
|
||||||
songAdapter.setPlaying(null, isPlaying)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -23,7 +23,6 @@ import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
||||||
|
@ -67,20 +66,11 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
|
||||||
// Use expected sw* size thresholds when choosing a configuration.
|
// Use expected sw* size thresholds when choosing a configuration.
|
||||||
when {
|
when {
|
||||||
// On small screens, only display an icon.
|
// On small screens, only display an icon.
|
||||||
width < 370 -> {
|
width < 370 -> tab.setIcon(icon).setContentDescription(string)
|
||||||
logD("Using icon-only configuration")
|
|
||||||
tab.setIcon(icon).setContentDescription(string)
|
|
||||||
}
|
|
||||||
// On large screens, display an icon and text.
|
// On large screens, display an icon and text.
|
||||||
width < 600 -> {
|
width < 600 -> tab.setText(string)
|
||||||
logD("Using text-only configuration")
|
|
||||||
tab.setText(string)
|
|
||||||
}
|
|
||||||
// On medium-size screens, display text.
|
// On medium-size screens, display text.
|
||||||
else -> {
|
else -> tab.setIcon(icon).setText(string)
|
||||||
logD("Using icon-and-text configuration")
|
|
||||||
tab.setIcon(icon).setText(string)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.home.tabs
|
||||||
|
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.logE
|
import org.oxycblt.auxio.util.logE
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A representation of a library tab suitable for configuration.
|
* A representation of a library tab suitable for configuration.
|
||||||
|
@ -84,6 +85,10 @@ sealed class Tab(open val mode: MusicMode) {
|
||||||
fun toIntCode(tabs: Array<Tab>): Int {
|
fun toIntCode(tabs: Array<Tab>): Int {
|
||||||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||||
val distinct = tabs.distinctBy { it.mode }
|
val distinct = tabs.distinctBy { it.mode }
|
||||||
|
if (tabs.size != distinct.size) {
|
||||||
|
logW(
|
||||||
|
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||||
|
}
|
||||||
|
|
||||||
var sequence = 0
|
var sequence = 0
|
||||||
var shift = MAX_SEQUENCE_IDX * 4
|
var shift = MAX_SEQUENCE_IDX * 4
|
||||||
|
@ -127,6 +132,10 @@ sealed class Tab(open val mode: MusicMode) {
|
||||||
|
|
||||||
// Make sure there are no duplicate tabs
|
// Make sure there are no duplicate tabs
|
||||||
val distinct = tabs.distinctBy { it.mode }
|
val distinct = tabs.distinctBy { it.mode }
|
||||||
|
if (tabs.size != distinct.size) {
|
||||||
|
logW(
|
||||||
|
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||||
|
}
|
||||||
|
|
||||||
// For safety, return null if we have an empty or larger-than-expected tab array.
|
// For safety, return null if we have an empty or larger-than-expected tab array.
|
||||||
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
|
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||||
|
@ -52,6 +53,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
||||||
* @param newTabs The new array of tabs to show.
|
* @param newTabs The new array of tabs to show.
|
||||||
*/
|
*/
|
||||||
fun submitTabs(newTabs: Array<Tab>) {
|
fun submitTabs(newTabs: Array<Tab>) {
|
||||||
|
logD("Force-updating tab information")
|
||||||
tabs = newTabs
|
tabs = newTabs
|
||||||
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
@ -63,6 +65,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
||||||
* @param tab The new tab.
|
* @param tab The new tab.
|
||||||
*/
|
*/
|
||||||
fun setTab(at: Int, tab: Tab) {
|
fun setTab(at: Int, tab: Tab) {
|
||||||
|
logD("Updating tab [at: $at, tab: $tab]")
|
||||||
tabs[at] = tab
|
tabs[at] = tab
|
||||||
// Use a payload to avoid an item change animation.
|
// Use a payload to avoid an item change animation.
|
||||||
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
||||||
|
@ -75,6 +78,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
||||||
* @param b The position of the second tab to swap.
|
* @param b The position of the second tab to swap.
|
||||||
*/
|
*/
|
||||||
fun swapTabs(a: Int, b: Int) {
|
fun swapTabs(a: Int, b: Int) {
|
||||||
|
logD("Swapping tabs [a: $a, b: $b]")
|
||||||
val tmp = tabs[b]
|
val tmp = tabs[b]
|
||||||
tabs[b] = tabs[a]
|
tabs[b] = tabs[a]
|
||||||
tabs[a] = tmp
|
tabs[a] = tmp
|
||||||
|
|
|
@ -91,14 +91,15 @@ class TabCustomizeDialog :
|
||||||
// We will need the exact index of the tab to update on in order to
|
// We will need the exact index of the tab to update on in order to
|
||||||
// notify the adapter of the change.
|
// notify the adapter of the change.
|
||||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
|
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
|
||||||
val tab = tabAdapter.tabs[index]
|
val old = tabAdapter.tabs[index]
|
||||||
tabAdapter.setTab(
|
val new =
|
||||||
index,
|
when (old) {
|
||||||
when (tab) {
|
|
||||||
// Invert the visibility of the tab
|
// Invert the visibility of the tab
|
||||||
is Tab.Visible -> Tab.Invisible(tab.mode)
|
is Tab.Visible -> Tab.Invisible(old.mode)
|
||||||
is Tab.Invisible -> Tab.Visible(tab.mode)
|
is Tab.Invisible -> Tab.Visible(old.mode)
|
||||||
})
|
}
|
||||||
|
logD("Flipping tab visibility [from: $old to: $new]")
|
||||||
|
tabAdapter.setTab(index, new)
|
||||||
|
|
||||||
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
||||||
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
|
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
|
||||||
|
|
|
@ -63,7 +63,9 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
|
throw IllegalStateException()
|
||||||
|
}
|
||||||
|
|
||||||
// We use a custom drag handle, so disable the long press action.
|
// We use a custom drag handle, so disable the long press action.
|
||||||
override fun isLongPressDragEnabled() = false
|
override fun isLongPressDragEnabled() = false
|
||||||
|
|
|
@ -27,7 +27,6 @@ import coil.request.ImageRequest
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -97,16 +96,11 @@ constructor(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(listOf(song))
|
.data(listOf(song))
|
||||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||||
.size(Size.ORIGINAL)
|
.size(Size.ORIGINAL))
|
||||||
.transformations(SquareFrameTransform.INSTANCE))
|
|
||||||
// Override the target in order to deliver the bitmap to the given
|
|
||||||
// listener.
|
|
||||||
.target(
|
.target(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (currentHandle == handle) {
|
if (currentHandle == handle) {
|
||||||
// Has not been superseded by a new request, can deliver
|
|
||||||
// this result.
|
|
||||||
target.onCompleted(it.toBitmap())
|
target.onCompleted(it.toBitmap())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -114,8 +108,6 @@ constructor(
|
||||||
onError = {
|
onError = {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (currentHandle == handle) {
|
if (currentHandle == handle) {
|
||||||
// Has not been superseded by a new request, can deliver
|
|
||||||
// this result.
|
|
||||||
target.onCompleted(null)
|
target.onCompleted(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.oxycblt.auxio.image.extractor.*
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
|
|
@ -73,6 +73,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
|
|
||||||
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||||
if (key == getString(R.string.set_key_cover_mode)) {
|
if (key == getString(R.string.set_key_cover_mode)) {
|
||||||
|
logD("Dispatching cover mode setting change")
|
||||||
listener.onCoverModeChanged()
|
listener.onCoverModeChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.request.Options
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Song
|
||||||
|
|
||||||
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
Keyer<List<Song>> {
|
Keyer<List<Song>> {
|
||||||
override fun key(data: List<Song>, options: Options) =
|
override fun key(data: List<Song>, options: Options) =
|
||||||
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
|
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
class SongCoverFetcher
|
class SongCoverFetcher
|
||||||
|
|
|
@ -43,6 +43,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.min
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.guava.asDeferred
|
import kotlinx.coroutines.guava.asDeferred
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -50,11 +51,16 @@ import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logE
|
||||||
import org.oxycblt.auxio.util.logW
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides functionality for extracting album cover information. Meant for internal use only.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
class CoverExtractor
|
class CoverExtractor
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -62,28 +68,69 @@ constructor(
|
||||||
private val imageSettings: ImageSettings,
|
private val imageSettings: ImageSettings,
|
||||||
private val mediaSourceFactory: MediaSource.Factory
|
private val mediaSourceFactory: MediaSource.Factory
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
|
||||||
|
*
|
||||||
|
* @param songs The [Song]s to load.
|
||||||
|
* @param size The [Size] of the image to load.
|
||||||
|
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
|
||||||
|
* will be returned of a mosaic composed of four album covers ordered by
|
||||||
|
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
|
||||||
|
*/
|
||||||
suspend fun extract(songs: List<Song>, size: Size): FetchResult? {
|
suspend fun extract(songs: List<Song>, size: Size): FetchResult? {
|
||||||
val albums = computeAlbumOrdering(songs)
|
val albums = computeCoverOrdering(songs)
|
||||||
val streams = mutableListOf<InputStream>()
|
val streams = mutableListOf<InputStream>()
|
||||||
for (album in albums) {
|
for (album in albums) {
|
||||||
openInputStream(album)?.let(streams::add)
|
openCoverInputStream(album)?.let(streams::add)
|
||||||
|
// We don't immediately check for mosaic feasibility from album count alone, as that
|
||||||
|
// does not factor in InputStreams failing to load. Instead, only check once we
|
||||||
|
// definitely have image data to use.
|
||||||
if (streams.size == 4) {
|
if (streams.size == 4) {
|
||||||
return createMosaic(streams, size)
|
// Make sure we free the InputStreams once we've transformed them into a mosaic.
|
||||||
|
return createMosaic(streams, size).also {
|
||||||
|
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return streams.firstOrNull()?.let { stream ->
|
// Not enough covers for a mosaic, take the first one (if that even exists)
|
||||||
SourceResult(
|
val first = streams.firstOrNull() ?: return null
|
||||||
source = ImageSource(stream.source().buffer(), context),
|
|
||||||
mimeType = null,
|
// All but the first stream will be unused, free their resources
|
||||||
dataSource = DataSource.DISK)
|
withContext(Dispatchers.IO) {
|
||||||
|
for (i in 1 until streams.size) {
|
||||||
|
streams[i].close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return SourceResult(
|
||||||
|
source = ImageSource(first.source().buffer(), context),
|
||||||
|
mimeType = null,
|
||||||
|
dataSource = DataSource.DISK)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun computeAlbumOrdering(songs: List<Song>) =
|
/**
|
||||||
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
|
* Creates an [Album] list representing the order that album covers would be used in [extract].
|
||||||
|
*
|
||||||
|
* @param songs A hypothetical list of [Song]s that would be used in [extract].
|
||||||
|
* @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then
|
||||||
|
* by their names. "Representation" is defined by how many [Song]s were found to be linked to
|
||||||
|
* the given [Album] in the given [Song] list.
|
||||||
|
*/
|
||||||
|
fun computeCoverOrdering(songs: List<Song>): List<Album> {
|
||||||
|
// TODO: Start short-circuiting in more places
|
||||||
|
if (songs.isEmpty()) return listOf()
|
||||||
|
if (songs.size == 1) return listOf(songs.first().album)
|
||||||
|
|
||||||
private suspend fun openInputStream(album: Album): InputStream? =
|
val sortedMap =
|
||||||
|
sortedMapOf<Album, Int>(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
|
||||||
|
for (song in songs) {
|
||||||
|
sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
|
||||||
|
}
|
||||||
|
return sortedMap.keys.sortedByDescending { sortedMap[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun openCoverInputStream(album: Album) =
|
||||||
try {
|
try {
|
||||||
when (imageSettings.coverMode) {
|
when (imageSettings.coverMode) {
|
||||||
CoverMode.OFF -> null
|
CoverMode.OFF -> null
|
||||||
|
@ -91,7 +138,7 @@ constructor(
|
||||||
CoverMode.QUALITY -> extractQualityCover(album)
|
CoverMode.QUALITY -> extractQualityCover(album)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logW("Unable to extract album cover due to an error: $e")
|
logE("Unable to extract album cover due to an error: $e")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,7 +195,6 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
|
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
|
||||||
logD("Front cover found")
|
|
||||||
stream = ByteArrayInputStream(pic)
|
stream = ByteArrayInputStream(pic)
|
||||||
break
|
break
|
||||||
} else if (stream == null) {
|
} else if (stream == null) {
|
||||||
|
@ -164,7 +210,7 @@ constructor(
|
||||||
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
||||||
|
|
||||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
private fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
||||||
// Use whatever size coil gives us to create the mosaic.
|
// Use whatever size coil gives us to create the mosaic.
|
||||||
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
||||||
val mosaicFrameSize =
|
val mosaicFrameSize =
|
||||||
|
@ -184,10 +230,9 @@ constructor(
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the bitmap through a transform to reflect the configuration of other images.
|
// Crop the bitmap down to a square so it leaves no empty space
|
||||||
val bitmap =
|
// TODO: Work around this
|
||||||
SquareFrameTransform.INSTANCE.transform(
|
val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
|
||||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||||
|
|
||||||
x += bitmap.width
|
x += bitmap.width
|
||||||
|
@ -206,14 +251,27 @@ constructor(
|
||||||
dataSource = DataSource.DISK)
|
dataSource = DataSource.DISK)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an image dimension suitable to create a mosaic with.
|
|
||||||
*
|
|
||||||
* @return A pixel dimension derived from the given [Dimension] that will always be even,
|
|
||||||
* allowing it to be sub-divided.
|
|
||||||
*/
|
|
||||||
private fun Dimension.mosaicSize(): Int {
|
private fun Dimension.mosaicSize(): Int {
|
||||||
|
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
||||||
|
// odd image sizes upwards to prevent the mosaic creation from failing.
|
||||||
val size = pxOrElse { 512 }
|
val size = pxOrElse { 512 }
|
||||||
return if (size.mod(2) > 0) size + 1 else size
|
return if (size.mod(2) > 0) size + 1 else size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cropBitmap(input: Bitmap, size: Size): Bitmap {
|
||||||
|
// Find the smaller dimension and then take a center portion of the image that
|
||||||
|
// has that size.
|
||||||
|
val dstSize = min(input.width, input.height)
|
||||||
|
val x = (input.width - dstSize) / 2
|
||||||
|
val y = (input.height - dstSize) / 2
|
||||||
|
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
|
||||||
|
|
||||||
|
val desiredWidth = size.width.pxOrElse { dstSize }
|
||||||
|
val desiredHeight = size.height.pxOrElse { dstSize }
|
||||||
|
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
||||||
|
// Image is not the desired size, upscale it.
|
||||||
|
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
// TODO: Consider breaking this up into sealed classes for individual adapters
|
||||||
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
|
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
|
||||||
interface Item
|
interface Item
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.list
|
package org.oxycblt.auxio.list
|
||||||
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.MenuRes
|
import androidx.annotation.MenuRes
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
@ -28,10 +27,17 @@ import androidx.viewbinding.ViewBinding
|
||||||
import org.oxycblt.auxio.MainFragmentDirections
|
import org.oxycblt.auxio.MainFragmentDirections
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.navigation.MainNavigationAction
|
import org.oxycblt.auxio.navigation.MainNavigationAction
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -83,32 +89,45 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
|
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
|
||||||
logD("Launching new song menu: ${song.name}")
|
logD("Launching new song menu: ${song.name}")
|
||||||
|
|
||||||
openMusicMenuImpl(anchor, menuRes) {
|
openMenu(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
setOnMenuItemClickListener {
|
||||||
R.id.action_play_next -> {
|
when (it.itemId) {
|
||||||
playbackModel.playNext(song)
|
R.id.action_play_next -> {
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
playbackModel.playNext(song)
|
||||||
}
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
R.id.action_queue_add -> {
|
true
|
||||||
playbackModel.addToQueue(song)
|
}
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
R.id.action_queue_add -> {
|
||||||
}
|
playbackModel.addToQueue(song)
|
||||||
R.id.action_go_artist -> {
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
navModel.exploreNavigateToParentArtist(song)
|
true
|
||||||
}
|
}
|
||||||
R.id.action_go_album -> {
|
R.id.action_go_artist -> {
|
||||||
navModel.exploreNavigateTo(song.album)
|
navModel.exploreNavigateToParentArtist(song)
|
||||||
}
|
true
|
||||||
R.id.action_playlist_add -> {
|
}
|
||||||
musicModel.addToPlaylist(song)
|
R.id.action_go_album -> {
|
||||||
}
|
navModel.exploreNavigateTo(song.album)
|
||||||
R.id.action_song_detail -> {
|
true
|
||||||
navModel.mainNavigateTo(
|
}
|
||||||
MainNavigationAction.Directions(
|
R.id.action_share -> {
|
||||||
MainFragmentDirections.actionShowDetails(song.uid)))
|
requireContext().share(song)
|
||||||
}
|
true
|
||||||
else -> {
|
}
|
||||||
error("Unexpected menu item selected")
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(song)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_song_detail -> {
|
||||||
|
navModel.mainNavigateTo(
|
||||||
|
MainNavigationAction.Directions(
|
||||||
|
MainFragmentDirections.actionShowDetails(song.uid)))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -125,30 +144,43 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
|
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
|
||||||
logD("Launching new album menu: ${album.name}")
|
logD("Launching new album menu: ${album.name}")
|
||||||
|
|
||||||
openMusicMenuImpl(anchor, menuRes) {
|
openMenu(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
setOnMenuItemClickListener {
|
||||||
R.id.action_play -> {
|
when (it.itemId) {
|
||||||
playbackModel.play(album)
|
R.id.action_play -> {
|
||||||
}
|
playbackModel.play(album)
|
||||||
R.id.action_shuffle -> {
|
true
|
||||||
playbackModel.shuffle(album)
|
}
|
||||||
}
|
R.id.action_shuffle -> {
|
||||||
R.id.action_play_next -> {
|
playbackModel.shuffle(album)
|
||||||
playbackModel.playNext(album)
|
true
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
}
|
||||||
}
|
R.id.action_play_next -> {
|
||||||
R.id.action_queue_add -> {
|
playbackModel.playNext(album)
|
||||||
playbackModel.addToQueue(album)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
true
|
||||||
}
|
}
|
||||||
R.id.action_go_artist -> {
|
R.id.action_queue_add -> {
|
||||||
navModel.exploreNavigateToParentArtist(album)
|
playbackModel.addToQueue(album)
|
||||||
}
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
R.id.action_playlist_add -> {
|
true
|
||||||
musicModel.addToPlaylist(album)
|
}
|
||||||
}
|
R.id.action_go_artist -> {
|
||||||
else -> {
|
navModel.exploreNavigateToParentArtist(album)
|
||||||
error("Unexpected menu item selected")
|
true
|
||||||
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(album)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
requireContext().share(album)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -165,27 +197,50 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
|
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
|
||||||
logD("Launching new artist menu: ${artist.name}")
|
logD("Launching new artist menu: ${artist.name}")
|
||||||
|
|
||||||
openMusicMenuImpl(anchor, menuRes) {
|
openMenu(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
val playable = artist.songs.isNotEmpty()
|
||||||
R.id.action_play -> {
|
if (!playable) {
|
||||||
playbackModel.play(artist)
|
logD("Artist is empty, disabling playback/playlist/share options")
|
||||||
}
|
}
|
||||||
R.id.action_shuffle -> {
|
menu.findItem(R.id.action_play).isEnabled = playable
|
||||||
playbackModel.shuffle(artist)
|
menu.findItem(R.id.action_shuffle).isEnabled = playable
|
||||||
}
|
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||||
R.id.action_play_next -> {
|
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||||
playbackModel.playNext(artist)
|
menu.findItem(R.id.action_playlist_add).isEnabled = playable
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
menu.findItem(R.id.action_share).isEnabled = playable
|
||||||
}
|
|
||||||
R.id.action_queue_add -> {
|
setOnMenuItemClickListener {
|
||||||
playbackModel.addToQueue(artist)
|
when (it.itemId) {
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
R.id.action_play -> {
|
||||||
}
|
playbackModel.play(artist)
|
||||||
R.id.action_playlist_add -> {
|
true
|
||||||
musicModel.addToPlaylist(artist)
|
}
|
||||||
}
|
R.id.action_shuffle -> {
|
||||||
else -> {
|
playbackModel.shuffle(artist)
|
||||||
error("Unexpected menu item selected")
|
true
|
||||||
|
}
|
||||||
|
R.id.action_play_next -> {
|
||||||
|
playbackModel.playNext(artist)
|
||||||
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_queue_add -> {
|
||||||
|
playbackModel.addToQueue(artist)
|
||||||
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(artist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
requireContext().share(artist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -202,27 +257,39 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
|
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
|
||||||
logD("Launching new genre menu: ${genre.name}")
|
logD("Launching new genre menu: ${genre.name}")
|
||||||
|
|
||||||
openMusicMenuImpl(anchor, menuRes) {
|
openMenu(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
setOnMenuItemClickListener {
|
||||||
R.id.action_play -> {
|
when (it.itemId) {
|
||||||
playbackModel.play(genre)
|
R.id.action_play -> {
|
||||||
}
|
playbackModel.play(genre)
|
||||||
R.id.action_shuffle -> {
|
true
|
||||||
playbackModel.shuffle(genre)
|
}
|
||||||
}
|
R.id.action_shuffle -> {
|
||||||
R.id.action_play_next -> {
|
playbackModel.shuffle(genre)
|
||||||
playbackModel.playNext(genre)
|
true
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
}
|
||||||
}
|
R.id.action_play_next -> {
|
||||||
R.id.action_queue_add -> {
|
playbackModel.playNext(genre)
|
||||||
playbackModel.addToQueue(genre)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
true
|
||||||
}
|
}
|
||||||
R.id.action_playlist_add -> {
|
R.id.action_queue_add -> {
|
||||||
musicModel.addToPlaylist(genre)
|
playbackModel.addToQueue(genre)
|
||||||
}
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
else -> {
|
true
|
||||||
error("Unexpected menu item selected")
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(genre)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
requireContext().share(genre)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -239,44 +306,51 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
|
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
|
||||||
logD("Launching new playlist menu: ${playlist.name}")
|
logD("Launching new playlist menu: ${playlist.name}")
|
||||||
|
|
||||||
openMusicMenuImpl(anchor, menuRes) {
|
|
||||||
when (it.itemId) {
|
|
||||||
R.id.action_play -> {
|
|
||||||
playbackModel.play(playlist)
|
|
||||||
}
|
|
||||||
R.id.action_shuffle -> {
|
|
||||||
playbackModel.shuffle(playlist)
|
|
||||||
}
|
|
||||||
R.id.action_play_next -> {
|
|
||||||
playbackModel.playNext(playlist)
|
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
|
||||||
}
|
|
||||||
R.id.action_queue_add -> {
|
|
||||||
playbackModel.addToQueue(playlist)
|
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
|
||||||
}
|
|
||||||
R.id.action_rename -> {
|
|
||||||
musicModel.renamePlaylist(playlist)
|
|
||||||
}
|
|
||||||
R.id.action_delete -> {
|
|
||||||
musicModel.deletePlaylist(playlist)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
error("Unexpected menu item selected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openMusicMenuImpl(
|
|
||||||
anchor: View,
|
|
||||||
@MenuRes menuRes: Int,
|
|
||||||
onMenuItemClick: (MenuItem) -> Unit
|
|
||||||
) {
|
|
||||||
openMenu(anchor, menuRes) {
|
openMenu(anchor, menuRes) {
|
||||||
setOnMenuItemClickListener { item ->
|
val playable = playlist.songs.isNotEmpty()
|
||||||
onMenuItemClick(item)
|
menu.findItem(R.id.action_play).isEnabled = playable
|
||||||
true
|
menu.findItem(R.id.action_shuffle).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||||
|
menu.findItem(R.id.action_share).isEnabled = playable
|
||||||
|
|
||||||
|
setOnMenuItemClickListener {
|
||||||
|
when (it.itemId) {
|
||||||
|
R.id.action_play -> {
|
||||||
|
playbackModel.play(playlist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_shuffle -> {
|
||||||
|
playbackModel.shuffle(playlist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_play_next -> {
|
||||||
|
playbackModel.playNext(playlist)
|
||||||
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_queue_add -> {
|
||||||
|
playbackModel.addToQueue(playlist)
|
||||||
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_rename -> {
|
||||||
|
musicModel.renamePlaylist(playlist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_delete -> {
|
||||||
|
musicModel.deletePlaylist(playlist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
requireContext().share(playlist)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logW("Unexpected menu item selected")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -295,6 +369,8 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logD("Opening popup menu menu")
|
||||||
|
|
||||||
currentMenu =
|
currentMenu =
|
||||||
PopupMenu(requireContext(), anchor).apply {
|
PopupMenu(requireContext(), anchor).apply {
|
||||||
inflate(menuRes)
|
inflate(menuRes)
|
||||||
|
|
|
@ -22,8 +22,13 @@ import androidx.annotation.IdRes
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Sort.Mode
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.info.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
|
|
||||||
|
|
|
@ -20,10 +20,12 @@ package org.oxycblt.auxio.list.adapter
|
||||||
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.recyclerview.widget.*
|
import androidx.recyclerview.widget.AdapterListUpdateCallback
|
||||||
|
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A variant of ListDiffer with more flexible updates.
|
* A variant of ListDiffer with more flexible updates.
|
||||||
|
@ -45,15 +47,18 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
/**
|
/**
|
||||||
* Update the adapter with new data.
|
* Update the adapter with new data.
|
||||||
*
|
*
|
||||||
* @param newData The new list of data to update with.
|
* @param newList The new list of data to update with.
|
||||||
* @param instructions The [UpdateInstructions] to visually update the list with.
|
* @param instructions The [UpdateInstructions] to visually update the list with.
|
||||||
* @param callback Called when the update is completed. May be done asynchronously.
|
* @param callback Called when the update is completed. May be done asynchronously.
|
||||||
*/
|
*/
|
||||||
fun update(
|
fun update(
|
||||||
newData: List<T>,
|
newList: List<T>,
|
||||||
instructions: UpdateInstructions?,
|
instructions: UpdateInstructions?,
|
||||||
callback: (() -> Unit)? = null
|
callback: (() -> Unit)? = null
|
||||||
) = differ.update(newData, instructions, callback)
|
) {
|
||||||
|
logD("Updating list to ${newList.size} items with $instructions")
|
||||||
|
differ.update(newList, instructions, callback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -164,6 +169,7 @@ private class FlexibleListDiffer<T>(
|
||||||
) {
|
) {
|
||||||
// fast simple remove all
|
// fast simple remove all
|
||||||
if (newList.isEmpty()) {
|
if (newList.isEmpty()) {
|
||||||
|
logD("Short-circuiting diff to remove all")
|
||||||
val countRemoved = oldList.size
|
val countRemoved = oldList.size
|
||||||
currentList = emptyList()
|
currentList = emptyList()
|
||||||
// notify last, after list is updated
|
// notify last, after list is updated
|
||||||
|
@ -174,6 +180,7 @@ private class FlexibleListDiffer<T>(
|
||||||
|
|
||||||
// fast simple first insert
|
// fast simple first insert
|
||||||
if (oldList.isEmpty()) {
|
if (oldList.isEmpty()) {
|
||||||
|
logD("Short-circuiting diff to insert all")
|
||||||
currentList = newList
|
currentList = newList
|
||||||
// notify last, after list is updated
|
// notify last, after list is updated
|
||||||
updateCallback.onInserted(0, newList.size)
|
updateCallback.onInserted(0, newList.size)
|
||||||
|
@ -232,8 +239,10 @@ private class FlexibleListDiffer<T>(
|
||||||
throw AssertionError()
|
throw AssertionError()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
mainThreadExecutor.execute {
|
mainThreadExecutor.execute {
|
||||||
if (maxScheduledGeneration == runGeneration) {
|
if (maxScheduledGeneration == runGeneration) {
|
||||||
|
logD("Applying calculated diff")
|
||||||
currentList = newList
|
currentList = newList
|
||||||
result.dispatchUpdatesTo(updateCallback)
|
result.dispatchUpdatesTo(updateCallback)
|
||||||
callback?.invoke()
|
callback?.invoke()
|
||||||
|
|
|
@ -22,6 +22,7 @@ import android.view.View
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
||||||
|
@ -58,6 +59,8 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
* @param isPlaying Whether playback is ongoing or paused.
|
* @param isPlaying Whether playback is ongoing or paused.
|
||||||
*/
|
*/
|
||||||
fun setPlaying(item: T?, isPlaying: Boolean) {
|
fun setPlaying(item: T?, isPlaying: Boolean) {
|
||||||
|
logD("Updating playing item [old: $currentItem new: $item]")
|
||||||
|
|
||||||
var updatedItem = false
|
var updatedItem = false
|
||||||
if (currentItem != item) {
|
if (currentItem != item) {
|
||||||
val oldItem = currentItem
|
val oldItem = currentItem
|
||||||
|
@ -69,7 +72,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
if (pos > -1) {
|
if (pos > -1) {
|
||||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||||
} else {
|
} else {
|
||||||
logD("oldItem was not in adapter data")
|
logW("oldItem was not in adapter data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +82,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
if (pos > -1) {
|
if (pos > -1) {
|
||||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||||
} else {
|
} else {
|
||||||
logD("newItem was not in adapter data")
|
logW("newItem was not in adapter data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,7 +100,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
if (pos > -1) {
|
if (pos > -1) {
|
||||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||||
} else {
|
} else {
|
||||||
logD("newItem was not in adapter data")
|
logW("newItem was not in adapter data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import android.view.View
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
||||||
|
@ -54,6 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||||
|
|
||||||
selectedItems = newSelectedItems
|
selectedItems = newSelectedItems
|
||||||
for (i in currentList.indices) {
|
for (i in currentList.indices) {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.divider.MaterialDivider
|
import com.google.android.material.divider.MaterialDivider
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.list.recycler.DialogRecyclerView.ViewHolder
|
||||||
import org.oxycblt.auxio.util.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,6 +26,7 @@ import androidx.core.view.isInvisible
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
|
||||||
import org.oxycblt.auxio.util.getDimen
|
import org.oxycblt.auxio.util.getDimen
|
||||||
import org.oxycblt.auxio.util.getInteger
|
import org.oxycblt.auxio.util.getInteger
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -67,7 +68,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
// this is only done once when the item is initially picked up.
|
// this is only done once when the item is initially picked up.
|
||||||
// TODO: I think this is possible to improve with a raw ValueAnimator.
|
// TODO: I think this is possible to improve with a raw ValueAnimator.
|
||||||
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||||
logD("Lifting item")
|
logD("Lifting ViewHolder")
|
||||||
|
|
||||||
val bg = holder.background
|
val bg = holder.background
|
||||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
||||||
|
@ -109,7 +110,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
// This function can be called multiple times, so only start the animation when the view's
|
// This function can be called multiple times, so only start the animation when the view's
|
||||||
// translationZ is already non-zero.
|
// translationZ is already non-zero.
|
||||||
if (holder.root.translationZ != 0f) {
|
if (holder.root.translationZ != 0f) {
|
||||||
logD("Dropping item")
|
logD("Lifting ViewHolder")
|
||||||
|
|
||||||
val bg = holder.background
|
val bg = holder.background
|
||||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
||||||
|
@ -136,7 +137,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
// Long-press events are too buggy, only allow dragging with the handle.
|
// Long-press events are too buggy, only allow dragging with the handle.
|
||||||
final override fun isLongPressDragEnabled() = false
|
final override fun isLongPressDragEnabled() = false
|
||||||
|
|
||||||
/** Required [RecyclerView.ViewHolder] implementation that exposes the following. */
|
/** Required [RecyclerView.ViewHolder] implementation that exposes required fields */
|
||||||
interface ViewHolder {
|
interface ViewHolder {
|
||||||
/** Whether this [ViewHolder] can be moved right now. */
|
/** Whether this [ViewHolder] can be moved right now. */
|
||||||
val enabled: Boolean
|
val enabled: Boolean
|
||||||
|
|
|
@ -31,11 +31,16 @@ import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.music.areNamesTheSame
|
||||||
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
||||||
|
@ -59,7 +64,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.songAlbumCover.isPlaying = isPlaying
|
binding.songAlbumCover.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
@ -109,7 +114,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.parentImage.isPlaying = isPlaying
|
binding.parentImage.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
@ -169,7 +174,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.parentImage.isPlaying = isPlaying
|
binding.parentImage.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
@ -226,7 +231,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.parentImage.isPlaying = isPlaying
|
binding.parentImage.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
@ -283,7 +288,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
binding.root.isSelected = isActive
|
binding.root.isSelected = isActive
|
||||||
binding.parentImage.isPlaying = isPlaying
|
binding.parentImage.setPlaying(isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||||
|
@ -325,7 +330,6 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
|
||||||
* @param basicHeader The new [BasicHeader] to bind.
|
* @param basicHeader The new [BasicHeader] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(basicHeader: BasicHeader) {
|
fun bind(basicHeader: BasicHeader) {
|
||||||
logD(binding.context.getString(basicHeader.titleRes))
|
|
||||||
binding.title.text = binding.context.getString(basicHeader.titleRes)
|
binding.title.text = binding.context.getString(basicHeader.titleRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -79,6 +80,10 @@ abstract class SelectionFragment<VB : ViewBinding> :
|
||||||
playbackModel.shuffle(selectionModel.take())
|
playbackModel.shuffle(selectionModel.take())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_selection_share -> {
|
||||||
|
requireContext().share(selectionModel.take())
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] that manages the current selection.
|
* A [ViewModel] that manages the current selection.
|
||||||
|
@ -76,10 +85,19 @@ constructor(
|
||||||
* @param music The [Music] item to select.
|
* @param music The [Music] item to select.
|
||||||
*/
|
*/
|
||||||
fun select(music: Music) {
|
fun select(music: Music) {
|
||||||
|
if (music is MusicParent && music.songs.isEmpty()) {
|
||||||
|
logD("Cannot select empty parent, ignoring operation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val selected = _selected.value.toMutableList()
|
val selected = _selected.value.toMutableList()
|
||||||
if (!selected.remove(music)) {
|
if (!selected.remove(music)) {
|
||||||
|
logD("Adding $music to selection")
|
||||||
selected.add(music)
|
selected.add(music)
|
||||||
|
} else {
|
||||||
|
logD("Removed $music from selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
_selected.value = selected
|
_selected.value = selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,8 +106,9 @@ constructor(
|
||||||
*
|
*
|
||||||
* @return A list of [Song]s collated from each item selected.
|
* @return A list of [Song]s collated from each item selected.
|
||||||
*/
|
*/
|
||||||
fun take() =
|
fun take(): List<Song> {
|
||||||
_selected.value
|
logD("Taking selection")
|
||||||
|
return _selected.value
|
||||||
.flatMap {
|
.flatMap {
|
||||||
when (it) {
|
when (it) {
|
||||||
is Song -> listOf(it)
|
is Song -> listOf(it)
|
||||||
|
@ -99,12 +118,16 @@ constructor(
|
||||||
is Playlist -> it.songs
|
is Playlist -> it.songs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.also { drop() }
|
.also { _selected.value = listOf() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the current selection.
|
* Clear the current selection.
|
||||||
*
|
*
|
||||||
* @return true if the prior selection was non-empty, false otherwise.
|
* @return true if the prior selection was non-empty, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun drop() = _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
fun drop(): Boolean {
|
||||||
|
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||||
|
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -314,21 +314,23 @@ interface Album : MusicParent {
|
||||||
*/
|
*/
|
||||||
interface Artist : MusicParent {
|
interface Artist : MusicParent {
|
||||||
/**
|
/**
|
||||||
* All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist
|
* All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums].
|
||||||
* will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus
|
* Note that any [Song] credited to this artist will have it's [Album] considered to be
|
||||||
* included in this list.
|
* "indirectly" linked to this [Artist], and thus included in this list.
|
||||||
*/
|
*/
|
||||||
val albums: List<Album>
|
val albums: List<Album>
|
||||||
|
|
||||||
|
/** Albums directly credited to this [Artist] via a "Album Artist" tag. */
|
||||||
|
val explicitAlbums: List<Album>
|
||||||
|
|
||||||
|
/** Albums indirectly credited to this [Artist] via an "Artist" tag. */
|
||||||
|
val implicitAlbums: List<Album>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
|
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
|
||||||
* songs.
|
* songs.
|
||||||
*/
|
*/
|
||||||
val durationMs: Long?
|
val durationMs: Long?
|
||||||
/**
|
|
||||||
* Whether this artist is considered a "collaborator", i.e it is not directly credited on any
|
|
||||||
* [Album].
|
|
||||||
*/
|
|
||||||
val isCollaborator: Boolean
|
|
||||||
/** The [Genre]s of this artist. */
|
/** The [Genre]s of this artist. */
|
||||||
val genres: List<Genre>
|
val genres: List<Genre>
|
||||||
}
|
}
|
||||||
|
@ -339,8 +341,6 @@ interface Artist : MusicParent {
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface Genre : MusicParent {
|
interface Genre : MusicParent {
|
||||||
/** The albums indirectly linked to by the [Song]s of this [Genre]. */
|
|
||||||
val albums: List<Album>
|
|
||||||
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */
|
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */
|
||||||
val artists: List<Artist>
|
val artists: List<Artist>
|
||||||
/** The total duration of the songs in this genre, in milliseconds. */
|
/** The total duration of the songs in this genre, in milliseconds. */
|
||||||
|
@ -353,8 +353,6 @@ interface Genre : MusicParent {
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface Playlist : MusicParent {
|
interface Playlist : MusicParent {
|
||||||
/** The albums indirectly linked to by the [Song]s of this [Playlist]. */
|
|
||||||
val albums: List<Album>
|
|
||||||
/** The total duration of the songs in this genre, in milliseconds. */
|
/** The total duration of the songs in this genre, in milliseconds. */
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,12 +21,18 @@ package org.oxycblt.auxio.music
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import java.util.*
|
import java.util.LinkedList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.music.cache.CacheRepository
|
import org.oxycblt.auxio.music.cache.CacheRepository
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
|
@ -45,6 +51,9 @@ import org.oxycblt.auxio.util.logW
|
||||||
* music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
|
* music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*
|
||||||
|
* TODO: Switch listeners to set when you can confirm there are no order-dependent listener
|
||||||
|
* configurations
|
||||||
*/
|
*/
|
||||||
interface MusicRepository {
|
interface MusicRepository {
|
||||||
/** The current music information found on the device. */
|
/** The current music information found on the device. */
|
||||||
|
@ -230,24 +239,32 @@ constructor(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||||
|
logD("Adding $listener to update listeners")
|
||||||
updateListeners.add(listener)
|
updateListeners.add(listener)
|
||||||
listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
|
listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||||
updateListeners.remove(listener)
|
logD("Removing $listener to update listeners")
|
||||||
|
if (!updateListeners.remove(listener)) {
|
||||||
|
logW("Update listener $listener was not added prior, cannot remove")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||||
|
logD("Adding $listener to indexing listeners")
|
||||||
indexingListeners.add(listener)
|
indexingListeners.add(listener)
|
||||||
listener.onIndexingStateChanged()
|
listener.onIndexingStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||||
indexingListeners.remove(listener)
|
logD("Removing $listener from indexing listeners")
|
||||||
|
if (!indexingListeners.remove(listener)) {
|
||||||
|
logW("Indexing listener $listener was not added prior, cannot remove")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
@ -256,6 +273,7 @@ constructor(
|
||||||
logW("Worker is already registered")
|
logW("Worker is already registered")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logD("Registering worker $worker")
|
||||||
indexingWorker = worker
|
indexingWorker = worker
|
||||||
if (indexingState == null) {
|
if (indexingState == null) {
|
||||||
worker.requestIndex(true)
|
worker.requestIndex(true)
|
||||||
|
@ -268,6 +286,7 @@ constructor(
|
||||||
logW("Given worker did not match current worker")
|
logW("Given worker did not match current worker")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logD("Unregistering worker $worker")
|
||||||
indexingWorker = null
|
indexingWorker = null
|
||||||
currentIndexingState = null
|
currentIndexingState = null
|
||||||
}
|
}
|
||||||
|
@ -279,44 +298,42 @@ constructor(
|
||||||
|
|
||||||
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
logD("Creating playlist $name with ${songs.size} songs")
|
||||||
userLibrary.createPlaylist(name, songs)
|
userLibrary.createPlaylist(name, songs)
|
||||||
notifyUserLibraryChange()
|
emitLibraryChange(device = false, user = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
logD("Renaming $playlist to $name")
|
||||||
userLibrary.renamePlaylist(playlist, name)
|
userLibrary.renamePlaylist(playlist, name)
|
||||||
notifyUserLibraryChange()
|
emitLibraryChange(device = false, user = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
logD("Deleting $playlist")
|
||||||
userLibrary.deletePlaylist(playlist)
|
userLibrary.deletePlaylist(playlist)
|
||||||
notifyUserLibraryChange()
|
emitLibraryChange(device = false, user = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
logD("Adding ${songs.size} songs to $playlist")
|
||||||
userLibrary.addToPlaylist(playlist, songs)
|
userLibrary.addToPlaylist(playlist, songs)
|
||||||
notifyUserLibraryChange()
|
emitLibraryChange(device = false, user = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
logD("Rewriting $playlist with ${songs.size} songs")
|
||||||
userLibrary.rewritePlaylist(playlist, songs)
|
userLibrary.rewritePlaylist(playlist, songs)
|
||||||
notifyUserLibraryChange()
|
emitLibraryChange(device = false, user = true)
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun notifyUserLibraryChange() {
|
|
||||||
for (listener in updateListeners) {
|
|
||||||
listener.onMusicChanges(
|
|
||||||
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun requestIndex(withCache: Boolean) {
|
override fun requestIndex(withCache: Boolean) {
|
||||||
|
logD("Requesting index operation [cache=$withCache]")
|
||||||
indexingWorker?.requestIndex(withCache)
|
indexingWorker?.requestIndex(withCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -343,7 +360,7 @@ constructor(
|
||||||
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
||||||
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
|
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
|
||||||
PackageManager.PERMISSION_DENIED) {
|
PackageManager.PERMISSION_DENIED) {
|
||||||
logE("Permission check failed")
|
logE("Permissions were not granted")
|
||||||
// No permissions, signal that we can't do anything.
|
// No permissions, signal that we can't do anything.
|
||||||
throw NoAudioPermissionException()
|
throw NoAudioPermissionException()
|
||||||
}
|
}
|
||||||
|
@ -353,14 +370,16 @@ constructor(
|
||||||
emitLoading(IndexingProgress.Indeterminate)
|
emitLoading(IndexingProgress.Indeterminate)
|
||||||
|
|
||||||
// Do the initial query of the cache and media databases in parallel.
|
// Do the initial query of the cache and media databases in parallel.
|
||||||
logD("Starting queries")
|
logD("Starting MediaStore query")
|
||||||
val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() }
|
val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() }
|
||||||
val cache =
|
val cache =
|
||||||
if (withCache) {
|
if (withCache) {
|
||||||
|
logD("Reading cache")
|
||||||
cacheRepository.readCache()
|
cacheRepository.readCache()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
logD("Awaiting MediaStore query")
|
||||||
val query = mediaStoreQueryJob.await().getOrThrow()
|
val query = mediaStoreQueryJob.await().getOrThrow()
|
||||||
|
|
||||||
// Now start processing the queried song information in parallel. Songs that can't be
|
// Now start processing the queried song information in parallel. Songs that can't be
|
||||||
|
@ -369,11 +388,13 @@ constructor(
|
||||||
logD("Starting song discovery")
|
logD("Starting song discovery")
|
||||||
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
|
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||||
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
|
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||||
|
logD("Started MediaStore discovery")
|
||||||
val mediaStoreJob =
|
val mediaStoreJob =
|
||||||
worker.scope.tryAsync {
|
worker.scope.tryAsync {
|
||||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||||
incompleteSongs.close()
|
incompleteSongs.close()
|
||||||
}
|
}
|
||||||
|
logD("Started ExoPlayer discovery")
|
||||||
val metadataJob =
|
val metadataJob =
|
||||||
worker.scope.tryAsync {
|
worker.scope.tryAsync {
|
||||||
tagExtractor.consume(incompleteSongs, completeSongs)
|
tagExtractor.consume(incompleteSongs, completeSongs)
|
||||||
|
@ -386,7 +407,8 @@ constructor(
|
||||||
rawSongs.add(rawSong)
|
rawSongs.add(rawSong)
|
||||||
emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
||||||
}
|
}
|
||||||
// These should be no-ops
|
logD("Awaiting discovery completion")
|
||||||
|
// These should be no-ops, but we need the error state to see if we should keep going.
|
||||||
mediaStoreJob.await().getOrThrow()
|
mediaStoreJob.await().getOrThrow()
|
||||||
metadataJob.await().getOrThrow()
|
metadataJob.await().getOrThrow()
|
||||||
|
|
||||||
|
@ -401,25 +423,47 @@ constructor(
|
||||||
// TODO: Indicate playlist state in loading process?
|
// TODO: Indicate playlist state in loading process?
|
||||||
emitLoading(IndexingProgress.Indeterminate)
|
emitLoading(IndexingProgress.Indeterminate)
|
||||||
val deviceLibraryChannel = Channel<DeviceLibrary>()
|
val deviceLibraryChannel = Channel<DeviceLibrary>()
|
||||||
|
logD("Starting DeviceLibrary creation")
|
||||||
val deviceLibraryJob =
|
val deviceLibraryJob =
|
||||||
worker.scope.tryAsync(Dispatchers.Main) {
|
worker.scope.tryAsync(Dispatchers.Default) {
|
||||||
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
|
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
|
||||||
}
|
}
|
||||||
|
logD("Starting UserLibrary creation")
|
||||||
val userLibraryJob =
|
val userLibraryJob =
|
||||||
worker.scope.tryAsync {
|
worker.scope.tryAsync {
|
||||||
userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() }
|
userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() }
|
||||||
}
|
}
|
||||||
if (cache == null || cache.invalidated) {
|
if (cache == null || cache.invalidated) {
|
||||||
|
logD("Writing cache [why=${cache?.invalidated}]")
|
||||||
cacheRepository.writeCache(rawSongs)
|
cacheRepository.writeCache(rawSongs)
|
||||||
}
|
}
|
||||||
|
logD("Awaiting library creation")
|
||||||
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
||||||
val userLibrary = userLibraryJob.await().getOrThrow()
|
val userLibrary = userLibraryJob.await().getOrThrow()
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
emitComplete(null)
|
logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]")
|
||||||
emitData(deviceLibrary, userLibrary)
|
emitComplete(null)
|
||||||
|
|
||||||
|
// Comparing the library instances is obscenely expensive, do it within the library
|
||||||
|
val deviceLibraryChanged = this.deviceLibrary != deviceLibrary
|
||||||
|
val userLibraryChanged = this.userLibrary != userLibrary
|
||||||
|
if (!deviceLibraryChanged && !userLibraryChanged) {
|
||||||
|
logD("Library has not changed, skipping update")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
synchronized(this) {
|
||||||
|
this.deviceLibrary = deviceLibrary
|
||||||
|
this.userLibrary = userLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
emitLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extension of [async] that forces the outcome to a [Result] to allow exceptions to bubble
|
||||||
|
* upwards instead of crashing the entire app.
|
||||||
|
*/
|
||||||
private inline fun <R> CoroutineScope.tryAsync(
|
private inline fun <R> CoroutineScope.tryAsync(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
crossinline block: suspend () -> R
|
crossinline block: suspend () -> R
|
||||||
|
@ -447,6 +491,7 @@ constructor(
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
previousCompletedState = IndexingState.Completed(error)
|
previousCompletedState = IndexingState.Completed(error)
|
||||||
currentIndexingState = null
|
currentIndexingState = null
|
||||||
|
logD("Dispatching completion state [error=$error]")
|
||||||
for (listener in indexingListeners) {
|
for (listener in indexingListeners) {
|
||||||
listener.onIndexingStateChanged()
|
listener.onIndexingStateChanged()
|
||||||
}
|
}
|
||||||
|
@ -454,14 +499,9 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) {
|
private fun emitLibraryChange(device: Boolean, user: Boolean) {
|
||||||
val deviceLibraryChanged = this.deviceLibrary != deviceLibrary
|
val changes = MusicRepository.Changes(device, user)
|
||||||
val userLibraryChanged = this.userLibrary != userLibrary
|
logD("Dispatching library change [changes=$changes]")
|
||||||
if (!deviceLibraryChanged && !userLibraryChanged) return
|
|
||||||
|
|
||||||
this.deviceLibrary = deviceLibrary
|
|
||||||
this.userLibrary = userLibrary
|
|
||||||
val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged)
|
|
||||||
for (listener in updateListeners) {
|
for (listener in updateListeners) {
|
||||||
listener.onMusicChanges(changes)
|
listener.onMusicChanges(changes)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.fs.Directory
|
||||||
import org.oxycblt.auxio.music.fs.MusicDirectories
|
import org.oxycblt.auxio.music.fs.MusicDirectories
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User configuration specific to music system.
|
* User configuration specific to music system.
|
||||||
|
@ -231,8 +232,14 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
getString(R.string.set_key_music_dirs),
|
getString(R.string.set_key_music_dirs),
|
||||||
getString(R.string.set_key_music_dirs_include),
|
getString(R.string.set_key_music_dirs_include),
|
||||||
getString(R.string.set_key_separators),
|
getString(R.string.set_key_separators),
|
||||||
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged()
|
getString(R.string.set_key_auto_sort_names) -> {
|
||||||
getString(R.string.set_key_observing) -> listener.onObservingChanged()
|
logD("Dispatching indexing setting change for $key")
|
||||||
|
listener.onIndexingSettingChanged()
|
||||||
|
}
|
||||||
|
getString(R.string.set_key_observing) -> {
|
||||||
|
logD("Dispatching observing setting change")
|
||||||
|
listener.onObservingChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] providing data specific to the music loading process.
|
* A [ViewModel] providing data specific to the music loading process.
|
||||||
|
@ -89,6 +90,7 @@ constructor(
|
||||||
deviceLibrary.artists.size,
|
deviceLibrary.artists.size,
|
||||||
deviceLibrary.genres.size,
|
deviceLibrary.genres.size,
|
||||||
deviceLibrary.songs.sumOf { it.durationMs })
|
deviceLibrary.songs.sumOf { it.durationMs })
|
||||||
|
logD("Updated statistics: ${_statistics.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIndexingStateChanged() {
|
override fun onIndexingStateChanged() {
|
||||||
|
@ -97,11 +99,13 @@ constructor(
|
||||||
|
|
||||||
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
logD("Refreshing library")
|
||||||
musicRepository.requestIndex(true)
|
musicRepository.requestIndex(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests that the music library be re-loaded without the cache. */
|
/** Requests that the music library be re-loaded without the cache. */
|
||||||
fun rescan() {
|
fun rescan() {
|
||||||
|
logD("Rescanning library")
|
||||||
musicRepository.requestIndex(false)
|
musicRepository.requestIndex(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,8 +117,10 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
|
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
|
logD("Creating $name with ${songs.size} songs]")
|
||||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
|
||||||
} else {
|
} else {
|
||||||
|
logD("Launching creation dialog for ${songs.size} songs")
|
||||||
_newPlaylistSongs.put(songs)
|
_newPlaylistSongs.put(songs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -127,8 +133,10 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun renamePlaylist(playlist: Playlist, name: String? = null) {
|
fun renamePlaylist(playlist: Playlist, name: String? = null) {
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
|
logD("Renaming $playlist to $name")
|
||||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
|
||||||
} else {
|
} else {
|
||||||
|
logD("Launching rename dialog for $playlist")
|
||||||
_playlistToRename.put(playlist)
|
_playlistToRename.put(playlist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,8 +150,10 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
|
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
|
||||||
if (rude) {
|
if (rude) {
|
||||||
|
logD("Deleting $playlist")
|
||||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
|
||||||
} else {
|
} else {
|
||||||
|
logD("Launching deletion dialog for $playlist")
|
||||||
_playlistToDelete.put(playlist)
|
_playlistToDelete.put(playlist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -155,6 +165,7 @@ constructor(
|
||||||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||||
*/
|
*/
|
||||||
fun addToPlaylist(song: Song, playlist: Playlist? = null) {
|
fun addToPlaylist(song: Song, playlist: Playlist? = null) {
|
||||||
|
logD("Adding $song to playlist")
|
||||||
addToPlaylist(listOf(song), playlist)
|
addToPlaylist(listOf(song), playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,6 +176,7 @@ constructor(
|
||||||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||||
*/
|
*/
|
||||||
fun addToPlaylist(album: Album, playlist: Playlist? = null) {
|
fun addToPlaylist(album: Album, playlist: Playlist? = null) {
|
||||||
|
logD("Adding $album to playlist")
|
||||||
addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist)
|
addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,6 +187,7 @@ constructor(
|
||||||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||||
*/
|
*/
|
||||||
fun addToPlaylist(artist: Artist, playlist: Playlist? = null) {
|
fun addToPlaylist(artist: Artist, playlist: Playlist? = null) {
|
||||||
|
logD("Adding $artist to playlist")
|
||||||
addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist)
|
addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -185,6 +198,7 @@ constructor(
|
||||||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||||
*/
|
*/
|
||||||
fun addToPlaylist(genre: Genre, playlist: Playlist? = null) {
|
fun addToPlaylist(genre: Genre, playlist: Playlist? = null) {
|
||||||
|
logD("Adding $genre to playlist")
|
||||||
addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist)
|
addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,8 +210,10 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
|
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
|
logD("Adding ${songs.size} songs to $playlist")
|
||||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
|
||||||
} else {
|
} else {
|
||||||
|
logD("Launching addition dialog for songs=${songs.size}")
|
||||||
_songsToAdd.put(songs)
|
_songsToAdd.put(songs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,8 @@ package org.oxycblt.auxio.music.cache
|
||||||
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A repository allowing access to cached metadata obtained in prior music loading operations.
|
* A repository allowing access to cached metadata obtained in prior music loading operations.
|
||||||
|
@ -49,7 +50,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached
|
||||||
try {
|
try {
|
||||||
// Faster to load the whole database into memory than do a query on each
|
// Faster to load the whole database into memory than do a query on each
|
||||||
// populate call.
|
// populate call.
|
||||||
CacheImpl(cachedSongsDao.readSongs())
|
val songs = cachedSongsDao.readSongs()
|
||||||
|
logD("Successfully read ${songs.size} songs from cache")
|
||||||
|
CacheImpl(songs)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logE("Unable to load cache database.")
|
logE("Unable to load cache database.")
|
||||||
logE(e.stackTraceToString())
|
logE(e.stackTraceToString())
|
||||||
|
@ -60,7 +63,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached
|
||||||
try {
|
try {
|
||||||
// Still write out whatever data was extracted.
|
// Still write out whatever data was extracted.
|
||||||
cachedSongsDao.nukeSongs()
|
cachedSongsDao.nukeSongs()
|
||||||
|
logD("Successfully deleted old cache")
|
||||||
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
|
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
|
||||||
|
logD("Successfully wrote ${rawSongs.size} songs to cache")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logE("Unable to save cache database.")
|
logE("Unable to save cache database.")
|
||||||
logE(e.stackTraceToString())
|
logE(e.stackTraceToString())
|
||||||
|
@ -96,7 +101,6 @@ private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
|
||||||
|
|
||||||
override var invalidated = false
|
override var invalidated = false
|
||||||
override fun populate(rawSong: RawSong): Boolean {
|
override fun populate(rawSong: RawSong): Boolean {
|
||||||
|
|
||||||
// For a cached raw song to be used, it must exist within the cache and have matching
|
// For a cached raw song to be used, it must exist within the cache and have matching
|
||||||
// addition and modification timestamps. Technically the addition timestamp doesn't
|
// addition and modification timestamps. Technically the addition timestamp doesn't
|
||||||
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
|
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
|
||||||
|
|
|
@ -23,7 +23,13 @@ import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||||
import org.oxycblt.auxio.music.fs.useQuery
|
import org.oxycblt.auxio.music.fs.useQuery
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -128,20 +134,11 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
|
||||||
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
|
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
|
||||||
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
|
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
// All other music is built from songs, so comparison only needs to check songs.
|
||||||
other is DeviceLibrary &&
|
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
|
||||||
other.songs == songs &&
|
override fun hashCode() = songs.hashCode()
|
||||||
other.albums == albums &&
|
override fun toString() =
|
||||||
other.artists == artists &&
|
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, artists=${artists.size}, genres=${genres.size})"
|
||||||
other.genres == genres
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var hashCode = songs.hashCode()
|
|
||||||
hashCode = hashCode * 31 + albums.hashCode()
|
|
||||||
hashCode = hashCode * 31 + artists.hashCode()
|
|
||||||
hashCode = hashCode * 31 + genres.hashCode()
|
|
||||||
return hashCode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun findSong(uid: Music.UID) = songUidMap[uid]
|
override fun findSong(uid: Music.UID) = songUidMap[uid]
|
||||||
override fun findAlbum(uid: Music.UID) = albumUidMap[uid]
|
override fun findAlbum(uid: Music.UID) = albumUidMap[uid]
|
||||||
|
@ -160,100 +157,69 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
|
||||||
songs.find { it.path.name == displayName && it.size == size }
|
songs.find { it.path.name == displayName && it.size == size }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings): List<SongImpl> {
|
||||||
* Build a list [SongImpl]s from the given [RawSong].
|
val start = System.currentTimeMillis()
|
||||||
*
|
val songs =
|
||||||
* @param rawSongs The [RawSong]s to build the [SongImpl]s from.
|
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
.songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid })
|
||||||
* @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for
|
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
||||||
* grouping.
|
return songs
|
||||||
*/
|
}
|
||||||
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
|
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
|
||||||
.songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a list of [Album]s from the given [Song]s.
|
|
||||||
*
|
|
||||||
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
|
|
||||||
* [Album]s when created.
|
|
||||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
|
||||||
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
|
|
||||||
* with parent [Artist] instances in order to be usable.
|
|
||||||
*/
|
|
||||||
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
|
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
// Group songs by their singular raw album, then map the raw instances and their
|
// Group songs by their singular raw album, then map the raw instances and their
|
||||||
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
|
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
|
||||||
val songsByAlbum = songs.groupBy { it.rawAlbum }
|
val songsByAlbum = songs.groupBy { it.rawAlbum.key }
|
||||||
val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) }
|
val albums = songsByAlbum.map { AlbumImpl(it.key.value, settings, it.value) }
|
||||||
logD("Successfully built ${albums.size} albums")
|
logD("Successfully built ${albums.size} albums in ${System.currentTimeMillis() - start}ms")
|
||||||
return albums
|
return albums
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
|
|
||||||
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
|
|
||||||
* artist names, and [Album]s being grouped primarily by album artist names.
|
|
||||||
*
|
|
||||||
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
|
|
||||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
|
||||||
* created.
|
|
||||||
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
|
|
||||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
|
||||||
* created.
|
|
||||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
|
||||||
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
|
|
||||||
* of [Song]s and [Album]s.
|
|
||||||
*/
|
|
||||||
private fun buildArtists(
|
private fun buildArtists(
|
||||||
songs: List<SongImpl>,
|
songs: List<SongImpl>,
|
||||||
albums: List<AlbumImpl>,
|
albums: List<AlbumImpl>,
|
||||||
settings: MusicSettings
|
settings: MusicSettings
|
||||||
): List<ArtistImpl> {
|
): List<ArtistImpl> {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
// Add every raw artist credited to each Song/Album to the grouping. This way,
|
// Add every raw artist credited to each Song/Album to the grouping. This way,
|
||||||
// different multi-artist combinations are not treated as different artists.
|
// different multi-artist combinations are not treated as different artists.
|
||||||
val musicByArtist = mutableMapOf<RawArtist, MutableList<Music>>()
|
// Songs and albums are grouped by artist and album artist respectively.
|
||||||
|
val musicByArtist = mutableMapOf<RawArtist.Key, MutableList<Music>>()
|
||||||
|
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
for (rawArtist in song.rawArtists) {
|
for (rawArtist in song.rawArtists) {
|
||||||
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
|
musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(song)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (album in albums) {
|
for (album in albums) {
|
||||||
for (rawArtist in album.rawArtists) {
|
for (rawArtist in album.rawArtists) {
|
||||||
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
|
musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(album)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the combined mapping into artist instances.
|
// Convert the combined mapping into artist instances.
|
||||||
val artists = musicByArtist.map { ArtistImpl(it.key, settings, it.value) }
|
val artists = musicByArtist.map { ArtistImpl(it.key.value, settings, it.value) }
|
||||||
logD("Successfully built ${artists.size} artists")
|
logD(
|
||||||
|
"Successfully built ${artists.size} artists in ${System.currentTimeMillis() - start}ms")
|
||||||
return artists
|
return artists
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Group up [Song]s into [Genre] instances.
|
|
||||||
*
|
|
||||||
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
|
|
||||||
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
|
|
||||||
* created.
|
|
||||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
|
||||||
* @return A non-empty list of [Genre]s.
|
|
||||||
*/
|
|
||||||
private fun buildGenres(songs: List<SongImpl>, settings: MusicSettings): List<GenreImpl> {
|
private fun buildGenres(songs: List<SongImpl>, settings: MusicSettings): List<GenreImpl> {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
// Add every raw genre credited to each Song to the grouping. This way,
|
// Add every raw genre credited to each Song to the grouping. This way,
|
||||||
// different multi-genre combinations are not treated as different genres.
|
// different multi-genre combinations are not treated as different genres.
|
||||||
val songsByGenre = mutableMapOf<RawGenre, MutableList<SongImpl>>()
|
val songsByGenre = mutableMapOf<RawGenre.Key, MutableList<SongImpl>>()
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
for (rawGenre in song.rawGenres) {
|
for (rawGenre in song.rawGenres) {
|
||||||
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
|
songsByGenre.getOrPut(rawGenre.key) { mutableListOf() }.add(song)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the mapping into genre instances.
|
// Convert the mapping into genre instances.
|
||||||
val genres = songsByGenre.map { GenreImpl(it.key, settings, it.value) }
|
val genres = songsByGenre.map { GenreImpl(it.key.value, settings, it.value) }
|
||||||
logD("Successfully built ${genres.size} genres")
|
logD("Successfully built ${genres.size} genres in ${System.currentTimeMillis() - start}ms")
|
||||||
return genres
|
return genres
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,5 +26,5 @@ import dagger.hilt.components.SingletonComponent
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface DeviceModule {
|
interface DeviceModule {
|
||||||
@Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
|
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,13 +20,21 @@ package org.oxycblt.auxio.music.device
|
||||||
|
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.fs.MimeType
|
import org.oxycblt.auxio.music.fs.MimeType
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
import org.oxycblt.auxio.music.fs.toAudioUri
|
import org.oxycblt.auxio.music.fs.toAudioUri
|
||||||
import org.oxycblt.auxio.music.fs.toCoverUri
|
import org.oxycblt.auxio.music.fs.toCoverUri
|
||||||
import org.oxycblt.auxio.music.info.*
|
|
||||||
import org.oxycblt.auxio.music.info.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
|
import org.oxycblt.auxio.music.info.Name
|
||||||
|
import org.oxycblt.auxio.music.info.ReleaseType
|
||||||
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
|
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.music.metadata.parseMultiValue
|
import org.oxycblt.auxio.music.metadata.parseMultiValue
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
@ -85,9 +93,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
||||||
override val album: Album
|
override val album: Album
|
||||||
get() = unlikelyToBeNull(_album)
|
get() = unlikelyToBeNull(_album)
|
||||||
|
|
||||||
override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode()
|
private val hashCode = 31 * uid.hashCode() + rawSong.hashCode()
|
||||||
|
|
||||||
|
override fun hashCode() = hashCode
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) =
|
||||||
other is SongImpl && uid == other.uid && rawSong == other.rawSong
|
other is SongImpl && uid == other.uid && rawSong == other.rawSong
|
||||||
|
override fun toString() = "Song(uid=$uid, name=$name)"
|
||||||
|
|
||||||
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||||
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
|
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
|
||||||
|
@ -237,44 +248,61 @@ class AlbumImpl(
|
||||||
update(rawAlbum.rawArtists.map { it.name })
|
update(rawAlbum.rawArtists.map { it.name })
|
||||||
}
|
}
|
||||||
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
|
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
|
||||||
|
override val dates: Date.Range?
|
||||||
override val dates = Date.Range.from(songs.mapNotNull { it.date })
|
|
||||||
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
||||||
override val coverUri = rawAlbum.mediaStoreId.toCoverUri()
|
override val coverUri = rawAlbum.mediaStoreId.toCoverUri()
|
||||||
override val durationMs: Long
|
override val durationMs: Long
|
||||||
override val dateAdded: Long
|
override val dateAdded: Long
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var hashCode = uid.hashCode()
|
|
||||||
hashCode = 31 * hashCode + rawAlbum.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
return hashCode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
|
|
||||||
|
|
||||||
private val _artists = mutableListOf<ArtistImpl>()
|
private val _artists = mutableListOf<ArtistImpl>()
|
||||||
override val artists: List<Artist>
|
override val artists: List<Artist>
|
||||||
get() = _artists
|
get() = _artists
|
||||||
|
|
||||||
|
private var hashCode = uid.hashCode()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
var totalDuration: Long = 0
|
var totalDuration: Long = 0
|
||||||
|
var minDate: Date? = null
|
||||||
|
var maxDate: Date? = null
|
||||||
var earliestDateAdded: Long = Long.MAX_VALUE
|
var earliestDateAdded: Long = Long.MAX_VALUE
|
||||||
|
|
||||||
// Do linking and value generation in the same loop for efficiency.
|
// Do linking and value generation in the same loop for efficiency.
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
song.link(this)
|
song.link(this)
|
||||||
|
|
||||||
|
if (song.date != null) {
|
||||||
|
val min = minDate
|
||||||
|
if (min == null || song.date < min) {
|
||||||
|
minDate = song.date
|
||||||
|
}
|
||||||
|
|
||||||
|
val max = maxDate
|
||||||
|
if (max == null || song.date > max) {
|
||||||
|
maxDate = song.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (song.dateAdded < earliestDateAdded) {
|
if (song.dateAdded < earliestDateAdded) {
|
||||||
earliestDateAdded = song.dateAdded
|
earliestDateAdded = song.dateAdded
|
||||||
}
|
}
|
||||||
totalDuration += song.durationMs
|
totalDuration += song.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val min = minDate
|
||||||
|
val max = maxDate
|
||||||
|
dates = if (min != null && max != null) Date.Range(min, max) else null
|
||||||
durationMs = totalDuration
|
durationMs = totalDuration
|
||||||
dateAdded = earliestDateAdded
|
dateAdded = earliestDateAdded
|
||||||
|
|
||||||
|
hashCode = 31 * hashCode + rawAlbum.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
|
||||||
|
override fun toString() = "Album(uid=$uid, name=$name)"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [RawArtist] instances collated by the [Album]. The album artists of the song take
|
* The [RawArtist] instances collated by the [Album]. The album artists of the song take
|
||||||
* priority, followed by the artists. If there are no artists, this field will be a single
|
* priority, followed by the artists. If there are no artists, this field will be a single
|
||||||
|
@ -336,17 +364,48 @@ class ArtistImpl(
|
||||||
|
|
||||||
override val songs: List<Song>
|
override val songs: List<Song>
|
||||||
override val albums: List<Album>
|
override val albums: List<Album>
|
||||||
|
override val explicitAlbums: List<Album>
|
||||||
|
override val implicitAlbums: List<Album>
|
||||||
override val durationMs: Long?
|
override val durationMs: Long?
|
||||||
override val isCollaborator: Boolean
|
|
||||||
|
override lateinit var genres: List<Genre>
|
||||||
|
|
||||||
|
private var hashCode = uid.hashCode()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val distinctSongs = mutableSetOf<Song>()
|
||||||
|
val albumMap = mutableMapOf<Album, Boolean>()
|
||||||
|
|
||||||
|
for (music in songAlbums) {
|
||||||
|
when (music) {
|
||||||
|
is SongImpl -> {
|
||||||
|
music.link(this)
|
||||||
|
distinctSongs.add(music)
|
||||||
|
if (albumMap[music.album] == null) {
|
||||||
|
albumMap[music.album] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AlbumImpl -> {
|
||||||
|
music.link(this)
|
||||||
|
albumMap[music] = true
|
||||||
|
}
|
||||||
|
else -> error("Unexpected input music ${music::class.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songs = distinctSongs.toList()
|
||||||
|
albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(albumMap.keys)
|
||||||
|
explicitAlbums = albums.filter { unlikelyToBeNull(albumMap[it]) }
|
||||||
|
implicitAlbums = albums.filterNot { unlikelyToBeNull(albumMap[it]) }
|
||||||
|
durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
|
||||||
|
|
||||||
|
hashCode = 31 * hashCode + rawArtist.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Append song contents to MusicParent equality so that artists with
|
// Note: Append song contents to MusicParent equality so that artists with
|
||||||
// the same UID but different songs are not equal.
|
// the same UID but different songs are not equal.
|
||||||
override fun hashCode(): Int {
|
override fun hashCode() = hashCode
|
||||||
var hashCode = uid.hashCode()
|
|
||||||
hashCode = 31 * hashCode + rawArtist.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
return hashCode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) =
|
||||||
other is ArtistImpl &&
|
other is ArtistImpl &&
|
||||||
|
@ -354,35 +413,7 @@ class ArtistImpl(
|
||||||
rawArtist == other.rawArtist &&
|
rawArtist == other.rawArtist &&
|
||||||
songs == other.songs
|
songs == other.songs
|
||||||
|
|
||||||
override lateinit var genres: List<Genre>
|
override fun toString() = "Artist(uid=$uid, name=$name)"
|
||||||
|
|
||||||
init {
|
|
||||||
val distinctSongs = mutableSetOf<Song>()
|
|
||||||
val distinctAlbums = mutableSetOf<Album>()
|
|
||||||
|
|
||||||
var noAlbums = true
|
|
||||||
|
|
||||||
for (music in songAlbums) {
|
|
||||||
when (music) {
|
|
||||||
is SongImpl -> {
|
|
||||||
music.link(this)
|
|
||||||
distinctSongs.add(music)
|
|
||||||
distinctAlbums.add(music.album)
|
|
||||||
}
|
|
||||||
is AlbumImpl -> {
|
|
||||||
music.link(this)
|
|
||||||
distinctAlbums.add(music)
|
|
||||||
noAlbums = false
|
|
||||||
}
|
|
||||||
else -> error("Unexpected input music ${music::class.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
songs = distinctSongs.toList()
|
|
||||||
albums = distinctAlbums.toList()
|
|
||||||
durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
|
|
||||||
isCollaborator = noAlbums
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
|
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
|
||||||
|
@ -393,7 +424,8 @@ class ArtistImpl(
|
||||||
* [RawArtist] will be within the list.
|
* [RawArtist] will be within the list.
|
||||||
* @return The index of the [Artist]'s [RawArtist] within the list.
|
* @return The index of the [Artist]'s [RawArtist] within the list.
|
||||||
*/
|
*/
|
||||||
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist)
|
fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
|
||||||
|
rawArtists.indexOfFirst { it.key == rawArtist.key }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform final validation and organization on this instance.
|
* Perform final validation and organization on this instance.
|
||||||
|
@ -427,19 +459,10 @@ class GenreImpl(
|
||||||
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
|
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
|
||||||
?: Name.Unknown(R.string.def_genre)
|
?: Name.Unknown(R.string.def_genre)
|
||||||
|
|
||||||
override val albums: List<Album>
|
|
||||||
override val artists: List<Artist>
|
override val artists: List<Artist>
|
||||||
override val durationMs: Long
|
override val durationMs: Long
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
private var hashCode = uid.hashCode()
|
||||||
var hashCode = uid.hashCode()
|
|
||||||
hashCode = 31 * hashCode + rawGenre.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
return hashCode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val distinctAlbums = mutableSetOf<Album>()
|
val distinctAlbums = mutableSetOf<Album>()
|
||||||
|
@ -453,14 +476,19 @@ class GenreImpl(
|
||||||
totalDuration += song.durationMs
|
totalDuration += song.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
albums =
|
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
|
||||||
.albums(distinctAlbums)
|
|
||||||
.sortedByDescending { album -> album.songs.count { it.genres.contains(this) } }
|
|
||||||
artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists)
|
artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists)
|
||||||
durationMs = totalDuration
|
durationMs = totalDuration
|
||||||
|
hashCode = 31 * hashCode + rawGenre.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
|
||||||
|
|
||||||
|
override fun toString() = "Genre(uid=$uid, name=$name)"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
|
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
|
||||||
* This can be used to create a consistent ordering within child [Genre] lists based on the
|
* This can be used to create a consistent ordering within child [Genre] lists based on the
|
||||||
|
@ -470,7 +498,8 @@ class GenreImpl(
|
||||||
* [RawGenre] will be within the list.
|
* [RawGenre] will be within the list.
|
||||||
* @return The index of the [Genre]'s [RawGenre] within the list.
|
* @return The index of the [Genre]'s [RawGenre] within the list.
|
||||||
*/
|
*/
|
||||||
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre)
|
fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
|
||||||
|
rawGenres.indexOfFirst { it.key == rawGenre.key }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform final validation and organization on this instance.
|
* Perform final validation and organization on this instance.
|
||||||
|
|
|
@ -19,7 +19,9 @@
|
||||||
package org.oxycblt.auxio.music.device
|
package org.oxycblt.auxio.music.device
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.fs.Directory
|
import org.oxycblt.auxio.music.fs.Directory
|
||||||
import org.oxycblt.auxio.music.info.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
import org.oxycblt.auxio.music.info.ReleaseType
|
||||||
|
@ -111,28 +113,35 @@ data class RawAlbum(
|
||||||
/** @see RawArtist.name */
|
/** @see RawArtist.name */
|
||||||
val rawArtists: List<RawArtist>
|
val rawArtists: List<RawArtist>
|
||||||
) {
|
) {
|
||||||
// Albums are grouped as follows:
|
val key = Key(this)
|
||||||
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
|
|
||||||
// same name to be differentiated, which is common in large libraries.
|
|
||||||
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
|
|
||||||
// artist name. This allows for case-insensitive artist/album grouping, which can be common
|
|
||||||
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
|
|
||||||
|
|
||||||
// Cache the hash-code for HashMap efficiency.
|
/** Exposed information that denotes [RawAlbum] uniqueness. */
|
||||||
private val hashCode =
|
data class Key(val value: RawAlbum) {
|
||||||
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
|
// Albums are grouped as follows:
|
||||||
|
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
|
||||||
|
// same name to be differentiated, which is common in large libraries.
|
||||||
|
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
|
||||||
|
// artist name. This allows for case-insensitive artist/album grouping, which can be common
|
||||||
|
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
// Cache the hash-code for HashMap efficiency.
|
||||||
|
private val hashCode =
|
||||||
|
value.musicBrainzId?.hashCode()
|
||||||
|
?: (31 * value.name.lowercase().hashCode() + value.rawArtists.hashCode())
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun hashCode() = hashCode
|
||||||
other is RawAlbum &&
|
|
||||||
when {
|
override fun equals(other: Any?) =
|
||||||
musicBrainzId != null && other.musicBrainzId != null ->
|
other is Key &&
|
||||||
musicBrainzId == other.musicBrainzId
|
when {
|
||||||
musicBrainzId == null && other.musicBrainzId == null ->
|
value.musicBrainzId != null && other.value.musicBrainzId != null ->
|
||||||
name.equals(other.name, true) && rawArtists == other.rawArtists
|
value.musicBrainzId == other.value.musicBrainzId
|
||||||
else -> false
|
value.musicBrainzId == null && other.value.musicBrainzId == null ->
|
||||||
}
|
other.value.name.equals(other.value.name, true) &&
|
||||||
|
other.value.rawArtists == other.value.rawArtists
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -149,33 +158,42 @@ data class RawArtist(
|
||||||
/** @see Music.name */
|
/** @see Music.name */
|
||||||
val sortName: String? = null
|
val sortName: String? = null
|
||||||
) {
|
) {
|
||||||
// Artists are grouped as follows:
|
val key = Key(this)
|
||||||
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
|
|
||||||
// same name to be differentiated, which is common in large libraries.
|
|
||||||
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
|
|
||||||
// grouping to be case-insensitive.
|
|
||||||
|
|
||||||
// Cache the hashCode for HashMap efficiency.
|
/**
|
||||||
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
|
* Allows [RawArtist]s to be compared by "fundamental" information that is unlikely to change on
|
||||||
|
* an item-by-item
|
||||||
|
*/
|
||||||
|
data class Key(val value: RawArtist) {
|
||||||
|
// Artists are grouped as follows:
|
||||||
|
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
|
||||||
|
// same name to be differentiated, which is common in large libraries.
|
||||||
|
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
|
||||||
|
// grouping to be case-insensitive.
|
||||||
|
|
||||||
// Compare names and MusicBrainz IDs in order to differentiate artists with the
|
// Cache the hashCode for HashMap efficiency.
|
||||||
// same name in large libraries.
|
private val hashCode = value.musicBrainzId?.hashCode() ?: value.name?.lowercase().hashCode()
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
// Compare names and MusicBrainz IDs in order to differentiate artists with the
|
||||||
|
// same name in large libraries.
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun hashCode() = hashCode
|
||||||
other is RawArtist &&
|
|
||||||
when {
|
override fun equals(other: Any?) =
|
||||||
musicBrainzId != null && other.musicBrainzId != null ->
|
other is Key &&
|
||||||
musicBrainzId == other.musicBrainzId
|
when {
|
||||||
musicBrainzId == null && other.musicBrainzId == null ->
|
value.musicBrainzId != null && other.value.musicBrainzId != null ->
|
||||||
when {
|
value.musicBrainzId == other.value.musicBrainzId
|
||||||
name != null && other.name != null -> name.equals(other.name, true)
|
value.musicBrainzId == null && other.value.musicBrainzId == null ->
|
||||||
name == null && other.name == null -> true
|
when {
|
||||||
else -> false
|
value.name != null && other.value.name != null ->
|
||||||
}
|
value.name.equals(other.value.name, true)
|
||||||
else -> false
|
value.name == null && other.value.name == null -> true
|
||||||
}
|
else -> false
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -187,20 +205,24 @@ data class RawGenre(
|
||||||
/** @see Music.name */
|
/** @see Music.name */
|
||||||
val name: String? = null
|
val name: String? = null
|
||||||
) {
|
) {
|
||||||
|
val key = Key(this)
|
||||||
|
|
||||||
// Cache the hashCode for HashMap efficiency.
|
data class Key(val value: RawGenre) {
|
||||||
private val hashCode = name?.lowercase().hashCode()
|
// Cache the hashCode for HashMap efficiency.
|
||||||
|
private val hashCode = value.name?.lowercase().hashCode()
|
||||||
|
|
||||||
// Only group by the lowercase genre name. This allows Genre grouping to be
|
// Only group by the lowercase genre name. This allows Genre grouping to be
|
||||||
// case-insensitive, which may be helpful in some libraries with different ways of
|
// case-insensitive, which may be helpful in some libraries with different ways of
|
||||||
// formatting genres.
|
// formatting genres.
|
||||||
override fun hashCode() = hashCode
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) =
|
||||||
other is RawGenre &&
|
other is Key &&
|
||||||
when {
|
when {
|
||||||
name != null && other.name != null -> name.equals(other.name, true)
|
value.name != null && other.value.name != null ->
|
||||||
name == null && other.name == null -> true
|
value.name.equals(other.value.name, true)
|
||||||
else -> false
|
value.name == null && other.value.name == null -> true
|
||||||
}
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ import org.oxycblt.auxio.databinding.ItemMusicDirBinding
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [RecyclerView.Adapter] that manages a list of [Directory] instances.
|
* [RecyclerView.Adapter] that manages a list of [Directory] instances.
|
||||||
|
@ -54,10 +55,8 @@ class DirectoryAdapter(private val listener: Listener) :
|
||||||
* @param dir The [Directory] to add.
|
* @param dir The [Directory] to add.
|
||||||
*/
|
*/
|
||||||
fun add(dir: Directory) {
|
fun add(dir: Directory) {
|
||||||
if (_dirs.contains(dir)) {
|
if (_dirs.contains(dir)) return
|
||||||
return
|
logD("Adding $dir")
|
||||||
}
|
|
||||||
|
|
||||||
_dirs.add(dir)
|
_dirs.add(dir)
|
||||||
notifyItemInserted(_dirs.lastIndex)
|
notifyItemInserted(_dirs.lastIndex)
|
||||||
}
|
}
|
||||||
|
@ -65,9 +64,10 @@ class DirectoryAdapter(private val listener: Listener) :
|
||||||
/**
|
/**
|
||||||
* Add a list of [Directory] instances to the end of the list.
|
* Add a list of [Directory] instances to the end of the list.
|
||||||
*
|
*
|
||||||
* @param dirs The [Directory instances to add.
|
* @param dirs The [Directory] instances to add.
|
||||||
*/
|
*/
|
||||||
fun addAll(dirs: List<Directory>) {
|
fun addAll(dirs: List<Directory>) {
|
||||||
|
logD("Adding ${dirs.size} directories")
|
||||||
val oldLastIndex = dirs.lastIndex
|
val oldLastIndex = dirs.lastIndex
|
||||||
_dirs.addAll(dirs)
|
_dirs.addAll(dirs)
|
||||||
notifyItemRangeInserted(oldLastIndex, dirs.size)
|
notifyItemRangeInserted(oldLastIndex, dirs.size)
|
||||||
|
@ -79,6 +79,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
||||||
* @param dir The [Directory] to remove. Must exist in the list.
|
* @param dir The [Directory] to remove. Must exist in the list.
|
||||||
*/
|
*/
|
||||||
fun remove(dir: Directory) {
|
fun remove(dir: Directory) {
|
||||||
|
logD("Removing $dir")
|
||||||
val idx = _dirs.indexOf(dir)
|
val idx = _dirs.indexOf(dir)
|
||||||
_dirs.removeAt(idx)
|
_dirs.removeAt(idx)
|
||||||
notifyItemRemoved(idx)
|
notifyItemRemoved(idx)
|
||||||
|
@ -86,6 +87,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
||||||
|
|
||||||
/** A Listener for [DirectoryAdapter] interactions. */
|
/** A Listener for [DirectoryAdapter] interactions. */
|
||||||
interface Listener {
|
interface Listener {
|
||||||
|
/** Called when the delete button on a directory item is clicked. */
|
||||||
fun onRemoveDirectory(dir: Directory)
|
fun onRemoveDirectory(dir: Directory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -145,6 +145,8 @@ data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolea
|
||||||
* @param fromFormat The mime type obtained by analyzing the file format. Null if could not be
|
* @param fromFormat The mime type obtained by analyzing the file format. Null if could not be
|
||||||
* obtained.
|
* obtained.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*
|
||||||
|
* TODO: Get around to simplifying this
|
||||||
*/
|
*/
|
||||||
data class MimeType(val fromExtension: String, val fromFormat: String?) {
|
data class MimeType(val fromExtension: String, val fromFormat: String?) {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -120,6 +120,7 @@ private abstract class BaseMediaStoreExtractor(
|
||||||
if (dirs.dirs.isNotEmpty()) {
|
if (dirs.dirs.isNotEmpty()) {
|
||||||
selector += " AND "
|
selector += " AND "
|
||||||
if (!dirs.shouldInclude) {
|
if (!dirs.shouldInclude) {
|
||||||
|
logD("Excluding directories in selector")
|
||||||
// Without a NOT, the query will be restricted to the specified paths, resulting
|
// Without a NOT, the query will be restricted to the specified paths, resulting
|
||||||
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
||||||
// resulting in the "Exclude" mode.
|
// resulting in the "Exclude" mode.
|
||||||
|
@ -144,14 +145,14 @@ private abstract class BaseMediaStoreExtractor(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now we can actually query MediaStore.
|
// Now we can actually query MediaStore.
|
||||||
logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")
|
logD("Starting song query [proj=${projection.toList()}, selector=$selector, args=$args]")
|
||||||
val cursor =
|
val cursor =
|
||||||
context.contentResolverSafe.safeQuery(
|
context.contentResolverSafe.safeQuery(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
projection,
|
projection,
|
||||||
selector,
|
selector,
|
||||||
args.toTypedArray())
|
args.toTypedArray())
|
||||||
logD("Song query succeeded [Projected total: ${cursor.count}]")
|
logD("Successfully queried for ${cursor.count} songs")
|
||||||
|
|
||||||
val genreNamesMap = mutableMapOf<Long, String>()
|
val genreNamesMap = mutableMapOf<Long, String>()
|
||||||
|
|
||||||
|
@ -186,6 +187,7 @@ private abstract class BaseMediaStoreExtractor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore")
|
||||||
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
||||||
return wrapQuery(cursor, genreNamesMap)
|
return wrapQuery(cursor, genreNamesMap)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import java.text.SimpleDateFormat
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.util.inRangeOrNull
|
import org.oxycblt.auxio.util.inRangeOrNull
|
||||||
|
import org.oxycblt.auxio.util.logE
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,33 +52,30 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
||||||
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
|
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
|
||||||
* be properly localized.
|
* be properly localized.
|
||||||
*/
|
*/
|
||||||
fun resolveDate(context: Context): String {
|
fun resolve(context: Context) =
|
||||||
if (month != null) {
|
|
||||||
// Parse a date format from an ISO-ish format
|
|
||||||
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
|
||||||
format.applyPattern("yyyy-MM")
|
|
||||||
val date =
|
|
||||||
try {
|
|
||||||
format.parse("$year-$month")
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (date != null) {
|
|
||||||
// Reformat as a readable month and year
|
|
||||||
format.applyPattern("MMM yyyy")
|
|
||||||
return format.format(date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unable to create fine-grained date, just format as a year.
|
// Unable to create fine-grained date, just format as a year.
|
||||||
return context.getString(R.string.fmt_number, year)
|
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
|
||||||
|
|
||||||
|
private fun resolveFineGrained(): String? {
|
||||||
|
// We can't directly load a date with our own
|
||||||
|
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
||||||
|
format.applyPattern("yyyy-MM")
|
||||||
|
val date =
|
||||||
|
try {
|
||||||
|
format.parse("$year-$month")
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
logE("Unable to parse fine-grained date: $e")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reformat as a readable month and year
|
||||||
|
format.applyPattern("MMM yyyy")
|
||||||
|
return format.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode() = tokens.hashCode()
|
|
||||||
|
|
||||||
override fun equals(other: Any?) = other is Date && compareTo(other) == 0
|
override fun equals(other: Any?) = other is Date && compareTo(other) == 0
|
||||||
|
override fun hashCode() = tokens.hashCode()
|
||||||
|
override fun toString() = StringBuilder().appendDate().toString()
|
||||||
override fun compareTo(other: Date): Int {
|
override fun compareTo(other: Date): Int {
|
||||||
for (i in 0 until max(tokens.size, other.tokens.size)) {
|
for (i in 0 until max(tokens.size, other.tokens.size)) {
|
||||||
val ai = tokens.getOrNull(i)
|
val ai = tokens.getOrNull(i)
|
||||||
|
@ -98,8 +96,6 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString() = StringBuilder().appendDate().toString()
|
|
||||||
|
|
||||||
private fun StringBuilder.appendDate(): StringBuilder {
|
private fun StringBuilder.appendDate(): StringBuilder {
|
||||||
// Construct an ISO-8601 date, dropping precision that doesn't exist.
|
// Construct an ISO-8601 date, dropping precision that doesn't exist.
|
||||||
append(year.toStringFixed(4))
|
append(year.toStringFixed(4))
|
||||||
|
@ -120,13 +116,15 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart
|
* @author Alexander Capehart
|
||||||
*/
|
*/
|
||||||
class Range
|
class Range(
|
||||||
private constructor(
|
|
||||||
/** The earliest [Date] in the range. */
|
/** The earliest [Date] in the range. */
|
||||||
val min: Date,
|
val min: Date,
|
||||||
/** the latest [Date] in the range. May be the same as [min]. ] */
|
/** the latest [Date] in the range. May be the same as [min]. ] */
|
||||||
val max: Date
|
val max: Date
|
||||||
) : Comparable<Range> {
|
) : Comparable<Range> {
|
||||||
|
init {
|
||||||
|
check(min <= max) { "Min date must be <= max date" }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve this instance into a human-readable date range.
|
* Resolve this instance into a human-readable date range.
|
||||||
|
@ -139,9 +137,9 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
||||||
fun resolveDate(context: Context) =
|
fun resolveDate(context: Context) =
|
||||||
if (min != max) {
|
if (min != max) {
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context))
|
R.string.fmt_date_range, min.resolve(context), max.resolve(context))
|
||||||
} else {
|
} else {
|
||||||
min.resolveDate(context)
|
min.resolve(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max
|
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max
|
||||||
|
@ -149,35 +147,6 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
||||||
override fun hashCode() = 31 * max.hashCode() + min.hashCode()
|
override fun hashCode() = 31 * max.hashCode() + min.hashCode()
|
||||||
|
|
||||||
override fun compareTo(other: Range) = min.compareTo(other.min)
|
override fun compareTo(other: Range) = min.compareTo(other.min)
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a [Range] from the given list of [Date]s.
|
|
||||||
*
|
|
||||||
* @param dates The [Date]s to use.
|
|
||||||
* @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s,
|
|
||||||
* null is returned.
|
|
||||||
*/
|
|
||||||
fun from(dates: List<Date>): Range? {
|
|
||||||
if (dates.isEmpty()) {
|
|
||||||
// Nothing to do.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// Simultaneously find the minimum and maximum values in the given range.
|
|
||||||
// If this list has only one item, then that one date is the minimum and maximum.
|
|
||||||
var min = dates.first()
|
|
||||||
var max = min
|
|
||||||
for (i in 1..dates.lastIndex) {
|
|
||||||
if (dates[i] < min) {
|
|
||||||
min = dates[i]
|
|
||||||
}
|
|
||||||
if (dates[i] > max) {
|
|
||||||
max = dates[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Range(min, max)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -27,6 +27,7 @@ import org.oxycblt.auxio.list.Item
|
||||||
* @param name The name of the disc group, if any. Null if not present.
|
* @param name The name of the disc group, if any. Null if not present.
|
||||||
*/
|
*/
|
||||||
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
|
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
|
||||||
|
// We don't want to group discs by differing subtitles, so only compare by the number
|
||||||
override fun equals(other: Any?) = other is Disc && number == other.number
|
override fun equals(other: Any?) = other is Disc && number == other.number
|
||||||
override fun hashCode() = number.hashCode()
|
override fun hashCode() = number.hashCode()
|
||||||
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
||||||
|
|
|
@ -174,6 +174,8 @@ private data class IntelligentKnownName(override val raw: String, override val s
|
||||||
override val sortTokens = parseTokens(sort ?: raw)
|
override val sortTokens = parseTokens(sort ?: raw)
|
||||||
|
|
||||||
private fun parseTokens(name: String): List<SortToken> {
|
private fun parseTokens(name: String): List<SortToken> {
|
||||||
|
// TODO: This routine is consuming much of the song building runtime, find a way to
|
||||||
|
// optimize it
|
||||||
val stripped =
|
val stripped =
|
||||||
name
|
name
|
||||||
// Remove excess punctuation from the string, as those u
|
// Remove excess punctuation from the string, as those u
|
||||||
|
@ -201,6 +203,7 @@ private data class IntelligentKnownName(override val raw: String, override val s
|
||||||
// Separate each token into their numeric and lexicographic counterparts.
|
// Separate each token into their numeric and lexicographic counterparts.
|
||||||
if (token.first().isDigit()) {
|
if (token.first().isDigit()) {
|
||||||
// The digit string comparison breaks with preceding zero digits, remove those
|
// The digit string comparison breaks with preceding zero digits, remove those
|
||||||
|
// TODO: Handle zero digits in other languages
|
||||||
val digits = token.trimStart('0').ifEmpty { token }
|
val digits = token.trimStart('0').ifEmpty { token }
|
||||||
// Other languages have other types of digit strings, still use collation keys
|
// Other languages have other types of digit strings, still use collation keys
|
||||||
collationKey = COLLATOR.getCollationKey(digits)
|
collationKey = COLLATOR.getCollationKey(digits)
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
package org.oxycblt.auxio.music.info
|
package org.oxycblt.auxio.music.info
|
||||||
|
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.music.info.ReleaseType.Album
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc.
|
* The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc.
|
||||||
|
|
|
@ -104,25 +104,23 @@ constructor(@ApplicationContext private val context: Context) : AudioProperties.
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val resolvedMimeType =
|
// The song's mime type won't have a populated format field right now, try to
|
||||||
if (song.mimeType.fromFormat != null) {
|
// extract it ourselves.
|
||||||
// ExoPlayer was already able to populate the format.
|
val formatMimeType =
|
||||||
song.mimeType
|
try {
|
||||||
} else {
|
format.getString(MediaFormat.KEY_MIME)
|
||||||
// ExoPlayer couldn't populate the format somehow, populate it here.
|
} catch (e: NullPointerException) {
|
||||||
val formatMimeType =
|
logE("Unable to extract mime type field")
|
||||||
try {
|
null
|
||||||
format.getString(MediaFormat.KEY_MIME)
|
|
||||||
} catch (e: NullPointerException) {
|
|
||||||
logE("Unable to extract mime type field")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
MimeType(song.mimeType.fromExtension, formatMimeType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extractor.release()
|
extractor.release()
|
||||||
|
|
||||||
return AudioProperties(bitrate, sampleRate, resolvedMimeType)
|
logD("Finished extracting audio properties")
|
||||||
|
|
||||||
|
return AudioProperties(
|
||||||
|
bitrate,
|
||||||
|
sampleRate,
|
||||||
|
MimeType(fromExtension = song.mimeType.fromExtension, fromFormat = formatMimeType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,12 +30,15 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
|
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
|
||||||
* split tags with multiple values.
|
* split tags with multiple values.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*
|
||||||
|
* TODO: Replace with unsplit names dialog
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
|
@ -74,7 +77,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
Separators.SLASH -> binding.separatorSlash.isChecked = true
|
Separators.SLASH -> binding.separatorSlash.isChecked = true
|
||||||
Separators.PLUS -> binding.separatorPlus.isChecked = true
|
Separators.PLUS -> binding.separatorPlus.isChecked = true
|
||||||
Separators.AND -> binding.separatorAnd.isChecked = true
|
Separators.AND -> binding.separatorAnd.isChecked = true
|
||||||
else -> error("Unexpected separator in settings data")
|
else -> logW("Unexpected separator in settings data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
|
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
|
||||||
|
@ -52,6 +53,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
|
||||||
// producing similar throughput's to other kinds of manual metadata extraction.
|
// producing similar throughput's to other kinds of manual metadata extraction.
|
||||||
val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
|
val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
|
||||||
|
|
||||||
|
logD("Beginning primary extraction loop")
|
||||||
|
|
||||||
for (incompleteRawSong in incompleteSongs) {
|
for (incompleteRawSong in incompleteSongs) {
|
||||||
spin@ while (true) {
|
spin@ while (true) {
|
||||||
for (i in tagWorkerPool.indices) {
|
for (i in tagWorkerPool.indices) {
|
||||||
|
@ -71,6 +74,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logD("All incomplete songs exhausted, starting cleanup loop")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
var ongoingTasks = false
|
var ongoingTasks = false
|
||||||
for (i in tagWorkerPool.indices) {
|
for (i in tagWorkerPool.indices) {
|
||||||
|
|
|
@ -39,6 +39,8 @@ fun List<String>.parseMultiValue(settings: MusicSettings) =
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Remove the escaping checks, it's too expensive to do this for every single tag.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
|
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
|
||||||
* the selector.
|
* the selector.
|
||||||
|
@ -106,7 +108,7 @@ fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
|
||||||
* @return A list of one or more [String]s that were split up by the user-defined separators.
|
* @return A list of one or more [String]s that were split up by the user-defined separators.
|
||||||
*/
|
*/
|
||||||
private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
|
private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
|
||||||
// Get the separators the user desires. If null, there's nothing to do.
|
if (settings.multiValueSeparators.isEmpty()) return listOf(this)
|
||||||
return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace()
|
return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -89,12 +89,8 @@ private class TagWorkerImpl(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logW("Unable to extract metadata for ${rawSong.name}")
|
logW("Unable to extract metadata for ${rawSong.name}")
|
||||||
logW(e.stackTraceToString())
|
logW(e.stackTraceToString())
|
||||||
null
|
return rawSong
|
||||||
}
|
}
|
||||||
if (format == null) {
|
|
||||||
logD("Nothing could be extracted for ${rawSong.name}")
|
|
||||||
return rawSong
|
|
||||||
}
|
|
||||||
|
|
||||||
val metadata = format.metadata
|
val metadata = format.metadata
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
|
|
|
@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -93,7 +94,7 @@ class AddToPlaylistDialog :
|
||||||
|
|
||||||
private fun updatePendingSongs(songs: List<Song>?) {
|
private fun updatePendingSongs(songs: List<Song>?) {
|
||||||
if (songs == null) {
|
if (songs == null) {
|
||||||
// No songs to feasibly add to a playlist, leave.
|
logD("No songs to show choices for, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -76,7 +77,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBindi
|
||||||
|
|
||||||
private fun updatePlaylistToDelete(playlist: Playlist?) {
|
private fun updatePlaylistToDelete(playlist: Playlist?) {
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
// Playlist does not exist anymore, leave
|
logD("No playlist to delete, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -89,6 +90,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
|
||||||
|
|
||||||
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
|
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
|
||||||
if (pendingPlaylist == null) {
|
if (pendingPlaylist == null) {
|
||||||
|
logD("No playlist to create, leaving")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,9 @@ import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logE
|
||||||
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] managing the state of the playlist picker dialogs.
|
* A [ViewModel] managing the state of the playlist picker dialogs.
|
||||||
|
@ -84,6 +87,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
pendingPlaylist.preferredName,
|
pendingPlaylist.preferredName,
|
||||||
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
|
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
|
||||||
}
|
}
|
||||||
|
logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}")
|
||||||
|
|
||||||
_currentSongsToAdd.value =
|
_currentSongsToAdd.value =
|
||||||
_currentSongsToAdd.value?.let { pendingSongs ->
|
_currentSongsToAdd.value?.let { pendingSongs ->
|
||||||
pendingSongs
|
pendingSongs
|
||||||
|
@ -91,6 +96,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
.ifEmpty { null }
|
.ifEmpty { null }
|
||||||
.also { refreshChoicesWith = it }
|
.also { refreshChoicesWith = it }
|
||||||
}
|
}
|
||||||
|
logD("Updated songs to add: ${_currentSongsToAdd.value?.size} songs")
|
||||||
}
|
}
|
||||||
|
|
||||||
val chosenName = _chosenName.value
|
val chosenName = _chosenName.value
|
||||||
|
@ -102,6 +108,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logD("Updated chosen name to $chosenName")
|
||||||
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
|
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,19 +126,34 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
* @param songUids The [Music.UID]s of songs to be present in the playlist.
|
* @param songUids The [Music.UID]s of songs to be present in the playlist.
|
||||||
*/
|
*/
|
||||||
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
|
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
logD("Opening ${songUids.size} songs to create a playlist from")
|
||||||
val songs = songUids.mapNotNull(deviceLibrary::findSong)
|
|
||||||
|
|
||||||
val userLibrary = musicRepository.userLibrary ?: return
|
val userLibrary = musicRepository.userLibrary ?: return
|
||||||
var i = 1
|
val songs =
|
||||||
while (true) {
|
musicRepository.deviceLibrary
|
||||||
val possibleName = context.getString(R.string.fmt_def_playlist, i)
|
?.let { songUids.mapNotNull(it::findSong) }
|
||||||
if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) {
|
?.also(::refreshPlaylistChoices)
|
||||||
_currentPendingPlaylist.value = PendingPlaylist(possibleName, songs)
|
|
||||||
return
|
val possibleName =
|
||||||
|
musicRepository.userLibrary?.let {
|
||||||
|
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
||||||
|
var i = 1
|
||||||
|
var possibleName: String
|
||||||
|
do {
|
||||||
|
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||||
|
logD("Trying $possibleName as a playlist name")
|
||||||
|
++i
|
||||||
|
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
|
||||||
|
logD("$possibleName is unique, using it as the playlist name")
|
||||||
|
possibleName
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPendingPlaylist.value =
|
||||||
|
if (possibleName != null && songs != null) {
|
||||||
|
PendingPlaylist(possibleName, songs)
|
||||||
|
} else {
|
||||||
|
logW("Given song UIDs to create were invalid")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
++i
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -140,7 +162,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
* @param playlistUid The [Music.UID]s of the [Playlist] to rename.
|
* @param playlistUid The [Music.UID]s of the [Playlist] to rename.
|
||||||
*/
|
*/
|
||||||
fun setPlaylistToRename(playlistUid: Music.UID) {
|
fun setPlaylistToRename(playlistUid: Music.UID) {
|
||||||
|
logD("Opening playlist $playlistUid to rename")
|
||||||
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||||
|
if (_currentPlaylistToDelete.value == null) {
|
||||||
|
logW("Given playlist UID to rename was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -149,7 +175,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
* @param playlistUid The [Music.UID] of the [Playlist] to delete.
|
* @param playlistUid The [Music.UID] of the [Playlist] to delete.
|
||||||
*/
|
*/
|
||||||
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
||||||
|
logD("Opening playlist $playlistUid to delete")
|
||||||
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||||
|
if (_currentPlaylistToDelete.value == null) {
|
||||||
|
logW("Given playlist UID to delete was invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -158,16 +188,25 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
* @param name The new user-inputted name, or null if not present.
|
* @param name The new user-inputted name, or null if not present.
|
||||||
*/
|
*/
|
||||||
fun updateChosenName(name: String?) {
|
fun updateChosenName(name: String?) {
|
||||||
|
logD("Updating chosen name to $name")
|
||||||
_chosenName.value =
|
_chosenName.value =
|
||||||
when {
|
when {
|
||||||
name.isNullOrEmpty() -> ChosenName.Empty
|
name.isNullOrEmpty() -> {
|
||||||
name.isBlank() -> ChosenName.Blank
|
logE("Chosen name is empty")
|
||||||
|
ChosenName.Empty
|
||||||
|
}
|
||||||
|
name.isBlank() -> {
|
||||||
|
logE("Chosen name is blank")
|
||||||
|
ChosenName.Blank
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val trimmed = name.trim()
|
val trimmed = name.trim()
|
||||||
val userLibrary = musicRepository.userLibrary
|
val userLibrary = musicRepository.userLibrary
|
||||||
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
|
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
|
||||||
|
logD("Chosen name is valid")
|
||||||
ChosenName.Valid(trimmed)
|
ChosenName.Valid(trimmed)
|
||||||
} else {
|
} else {
|
||||||
|
logD("Chosen name already exists in library")
|
||||||
ChosenName.AlreadyExists(trimmed)
|
ChosenName.AlreadyExists(trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -180,14 +219,19 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
* @param songUids The [Music.UID]s of songs to add to a playlist.
|
* @param songUids The [Music.UID]s of songs to add to a playlist.
|
||||||
*/
|
*/
|
||||||
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
logD("Opening ${songUids.size} songs to add to a playlist")
|
||||||
val songs = songUids.mapNotNull(deviceLibrary::findSong)
|
_currentSongsToAdd.value =
|
||||||
_currentSongsToAdd.value = songs
|
musicRepository.deviceLibrary
|
||||||
refreshPlaylistChoices(songs)
|
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
||||||
|
?.also(::refreshPlaylistChoices)
|
||||||
|
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
|
||||||
|
logW("Given song UIDs to add were (partially) invalid")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshPlaylistChoices(songs: List<Song>) {
|
private fun refreshPlaylistChoices(songs: List<Song>) {
|
||||||
val userLibrary = musicRepository.userLibrary ?: return
|
val userLibrary = musicRepository.userLibrary ?: return
|
||||||
|
logD("Refreshing playlist choices")
|
||||||
_playlistAddChoices.value =
|
_playlistAddChoices.value =
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
|
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
|
||||||
val songSet = it.songs.toSet()
|
val songSet = it.songs.toSet()
|
||||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -86,7 +87,9 @@ class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initializedField) {
|
if (!initializedField) {
|
||||||
requireBinding().playlistName.setText(playlist.name.resolve(requireContext()))
|
val default = playlist.name.resolve(requireContext())
|
||||||
|
logD("Name input is not initialized, setting to $default")
|
||||||
|
requireBinding().playlistName.setText(default)
|
||||||
initializedField = true
|
initializedField = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,12 +28,17 @@ import android.os.PowerManager
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import java.lang.Runnable
|
|
||||||
import java.util.*
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.IndexingProgress
|
||||||
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
|
@ -119,6 +124,7 @@ class IndexerService :
|
||||||
// --- CONTROLLER CALLBACKS ---
|
// --- CONTROLLER CALLBACKS ---
|
||||||
|
|
||||||
override fun requestIndex(withCache: Boolean) {
|
override fun requestIndex(withCache: Boolean) {
|
||||||
|
logD("Starting new indexing job")
|
||||||
// Cancel the previous music loading job.
|
// Cancel the previous music loading job.
|
||||||
currentIndexJob?.cancel()
|
currentIndexJob?.cancel()
|
||||||
// Start a new music loading job on a co-routine.
|
// Start a new music loading job on a co-routine.
|
||||||
|
@ -132,6 +138,7 @@ class IndexerService :
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
|
logD("Music changed, updating shared objects")
|
||||||
// Wipe possibly-invalidated outdated covers
|
// Wipe possibly-invalidated outdated covers
|
||||||
imageLoader.memoryCache?.clear()
|
imageLoader.memoryCache?.clear()
|
||||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||||
|
@ -187,11 +194,14 @@ class IndexerService :
|
||||||
// and thus the music library will not be updated at all.
|
// and thus the music library will not be updated at all.
|
||||||
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
||||||
// this anymore, or at least I only have to use it when the app task is not removed.
|
// this anymore, or at least I only have to use it when the app task is not removed.
|
||||||
|
logD("Need to observe, staying in foreground")
|
||||||
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
||||||
|
logD("Notification changed, re-posting notification")
|
||||||
observingNotification.post()
|
observingNotification.post()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not observing and done loading, exit foreground.
|
// Not observing and done loading, exit foreground.
|
||||||
|
logD("Exiting foreground")
|
||||||
foregroundManager.tryStopForeground()
|
foregroundManager.tryStopForeground()
|
||||||
}
|
}
|
||||||
// Release our wake lock (if we were using it)
|
// Release our wake lock (if we were using it)
|
||||||
|
@ -232,6 +242,7 @@ class IndexerService :
|
||||||
// setting changed. In such a case, the state will still be updated when
|
// setting changed. In such a case, the state will still be updated when
|
||||||
// the music loading process ends.
|
// the music loading process ends.
|
||||||
if (currentIndexJob == null) {
|
if (currentIndexJob == null) {
|
||||||
|
logD("Not loading, updating idle session")
|
||||||
updateIdleSession()
|
updateIdleSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -269,6 +280,7 @@ class IndexerService :
|
||||||
// Check here if we should even start a reindex. This is much less bug-prone than
|
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||||
// registering and de-registering this component as this setting changes.
|
// registering and de-registering this component as this setting changes.
|
||||||
if (musicSettings.shouldBeObserving) {
|
if (musicSettings.shouldBeObserving) {
|
||||||
|
logD("MediaStore changed, starting re-index")
|
||||||
requestIndex(true)
|
requestIndex(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,11 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.user
|
package org.oxycblt.auxio.music.user
|
||||||
|
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||||
import org.oxycblt.auxio.music.info.Name
|
import org.oxycblt.auxio.music.info.Name
|
||||||
|
|
||||||
|
@ -29,8 +33,17 @@ private constructor(
|
||||||
override val songs: List<Song>
|
override val songs: List<Song>
|
||||||
) : Playlist {
|
) : Playlist {
|
||||||
override val durationMs = songs.sumOf { it.durationMs }
|
override val durationMs = songs.sumOf { it.durationMs }
|
||||||
override val albums =
|
private var hashCode = uid.hashCode()
|
||||||
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
|
|
||||||
|
init {
|
||||||
|
hashCode = 31 * hashCode + name.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
override fun toString() = "Playlist(uid=$uid, name=$name)"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
|
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
|
||||||
|
@ -55,16 +68,6 @@ private constructor(
|
||||||
*/
|
*/
|
||||||
inline fun edit(edits: MutableList<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits))
|
inline fun edit(edits: MutableList<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits))
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var hashCode = uid.hashCode()
|
|
||||||
hashCode = 31 * hashCode + name.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
return hashCode
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Create a new instance with a novel UID.
|
* Create a new instance with a novel UID.
|
||||||
|
|
|
@ -18,7 +18,12 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.user
|
package org.oxycblt.auxio.music.user
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Junction
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Relation
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -18,10 +18,17 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.user
|
package org.oxycblt.auxio.music.user
|
||||||
|
|
||||||
|
import java.lang.Exception
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.logE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organized library information controlled by the user.
|
* Organized library information controlled by the user.
|
||||||
|
@ -118,7 +125,14 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus
|
||||||
UserLibrary.Factory {
|
UserLibrary.Factory {
|
||||||
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
|
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
|
||||||
// While were waiting for the library, read our playlists out.
|
// While were waiting for the library, read our playlists out.
|
||||||
val rawPlaylists = playlistDao.readRawPlaylists()
|
val rawPlaylists =
|
||||||
|
try {
|
||||||
|
playlistDao.readRawPlaylists()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logE("Unable to read playlists: $e")
|
||||||
|
return UserLibraryImpl(playlistDao, mutableMapOf(), musicSettings)
|
||||||
|
}
|
||||||
|
logD("Successfully read ${rawPlaylists.size} playlists")
|
||||||
val deviceLibrary = deviceLibraryChannel.receive()
|
val deviceLibrary = deviceLibraryChannel.receive()
|
||||||
// Convert the database playlist information to actual usable playlists.
|
// Convert the database playlist information to actual usable playlists.
|
||||||
val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
|
val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
|
||||||
|
@ -135,6 +149,10 @@ private class UserLibraryImpl(
|
||||||
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
|
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : MutableUserLibrary {
|
) : MutableUserLibrary {
|
||||||
|
override fun hashCode() = playlistMap.hashCode()
|
||||||
|
override fun equals(other: Any?) = other is UserLibraryImpl && other.playlistMap == playlistMap
|
||||||
|
override fun toString() = "UserLibrary(playlists=${playlists.size})"
|
||||||
|
|
||||||
override val playlists: List<Playlist>
|
override val playlists: List<Playlist>
|
||||||
get() = playlistMap.values.toList()
|
get() = playlistMap.values.toList()
|
||||||
|
|
||||||
|
@ -143,40 +161,81 @@ private class UserLibraryImpl(
|
||||||
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
|
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
|
||||||
|
|
||||||
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
|
// TODO: Use synchronized with value access too
|
||||||
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
|
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
|
||||||
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||||
val rawPlaylist =
|
val rawPlaylist =
|
||||||
RawPlaylist(
|
RawPlaylist(
|
||||||
PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw),
|
PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw),
|
||||||
playlistImpl.songs.map { PlaylistSong(it.uid) })
|
playlistImpl.songs.map { PlaylistSong(it.uid) })
|
||||||
playlistDao.insertPlaylist(rawPlaylist)
|
try {
|
||||||
|
playlistDao.insertPlaylist(rawPlaylist)
|
||||||
|
logD("Successfully created playlist $name with ${songs.size} songs")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logE("Unable to create playlist $name with ${songs.size} songs")
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
synchronized(this) { playlistMap.remove(playlistImpl.uid) }
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
val playlistImpl =
|
val playlistImpl =
|
||||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
|
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
|
||||||
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) }
|
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) }
|
||||||
playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name))
|
try {
|
||||||
|
playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name))
|
||||||
|
logD("Successfully renamed $playlist to $name")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logE("Unable to rename $playlist to $name: $e")
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
synchronized(this) {
|
val playlistImpl =
|
||||||
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
|
requireNotNull(playlistMap[playlist.uid]) { "Cannot remove invalid playlist" }
|
||||||
|
synchronized(this) { playlistMap.remove(playlistImpl.uid) }
|
||||||
|
try {
|
||||||
|
playlistDao.deletePlaylist(playlist.uid)
|
||||||
|
logD("Successfully deleted $playlist")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logE("Unable to delete $playlist: $e")
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
playlistDao.deletePlaylist(playlist.uid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
|
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
val playlistImpl =
|
val playlistImpl =
|
||||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
|
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
|
||||||
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } }
|
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } }
|
||||||
playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
try {
|
||||||
|
playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
||||||
|
logD("Successfully added ${songs.size} songs to $playlist")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logE("Unable to add ${songs.size} songs to $playlist: $e")
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
val playlistImpl =
|
val playlistImpl =
|
||||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" }
|
requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" }
|
||||||
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) }
|
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) }
|
||||||
playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
try {
|
||||||
|
playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
||||||
|
logD("Successfully rewrote $playlist with ${songs.size} songs")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logE("Unable to rewrite $playlist with ${songs.size} songs: $e")
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,14 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.user
|
package org.oxycblt.auxio.music.user
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.TypeConverters
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.logD
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
*
|
||||||
* TODO: This whole system is very jankily designed, perhaps it's time for a refactor?
|
* TODO: Unwind this into ViewModel-specific actions, and then reference those.
|
||||||
*/
|
*/
|
||||||
class NavigationViewModel : ViewModel() {
|
class NavigationViewModel : ViewModel() {
|
||||||
private val _mainNavigationAction = MutableEvent<MainNavigationAction>()
|
private val _mainNavigationAction = MutableEvent<MainNavigationAction>()
|
||||||
|
@ -96,6 +96,7 @@ class NavigationViewModel : ViewModel() {
|
||||||
* dialog will be shown.
|
* dialog will be shown.
|
||||||
*/
|
*/
|
||||||
fun exploreNavigateToParentArtist(song: Song) {
|
fun exploreNavigateToParentArtist(song: Song) {
|
||||||
|
logD("Navigating to parent artist of $song")
|
||||||
exploreNavigateToParentArtistImpl(song, song.artists)
|
exploreNavigateToParentArtistImpl(song, song.artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,6 +107,7 @@ class NavigationViewModel : ViewModel() {
|
||||||
* dialog will be shown.
|
* dialog will be shown.
|
||||||
*/
|
*/
|
||||||
fun exploreNavigateToParentArtist(album: Album) {
|
fun exploreNavigateToParentArtist(album: Album) {
|
||||||
|
logD("Navigating to parent artist of $album")
|
||||||
exploreNavigateToParentArtistImpl(album, album.artists)
|
exploreNavigateToParentArtistImpl(album, album.artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,7 +78,7 @@ class NavigateToArtistDialog :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {
|
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
choiceAdapter
|
binding.choiceRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
||||||
|
|
|
@ -23,7 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] that stores the current information required for navigation picker dialogs
|
* A [ViewModel] that stores the current information required for navigation picker dialogs
|
||||||
|
@ -58,6 +63,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
logD("Updated artist choices: ${_artistChoices.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
@ -71,12 +77,22 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
|
||||||
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
|
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
|
||||||
*/
|
*/
|
||||||
fun setArtistChoiceUid(itemUid: Music.UID) {
|
fun setArtistChoiceUid(itemUid: Music.UID) {
|
||||||
|
logD("Opening navigation choices for $itemUid")
|
||||||
// Support Songs and Albums, which have parent artists.
|
// Support Songs and Albums, which have parent artists.
|
||||||
_artistChoices.value =
|
_artistChoices.value =
|
||||||
when (val music = musicRepository.find(itemUid)) {
|
when (val music = musicRepository.find(itemUid)) {
|
||||||
is Song -> SongArtistNavigationChoices(music)
|
is Song -> {
|
||||||
is Album -> AlbumArtistNavigationChoices(music)
|
logD("Creating navigation choices for song")
|
||||||
else -> null
|
SongArtistNavigationChoices(music)
|
||||||
|
}
|
||||||
|
is Album -> {
|
||||||
|
logD("Creating navigation choices for album")
|
||||||
|
AlbumArtistNavigationChoices(music)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logD("Given song/album UID was invalid")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import com.google.android.material.R as MR
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
|
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
|
||||||
|
@ -33,6 +34,7 @@ import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.getColorCompat
|
import org.oxycblt.auxio.util.getColorCompat
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
|
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
|
||||||
|
@ -92,14 +94,16 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) {
|
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) {
|
||||||
when (actionMode) {
|
when (actionMode) {
|
||||||
ActionMode.NEXT -> {
|
ActionMode.NEXT -> {
|
||||||
|
logD("Setting up skip next action")
|
||||||
binding.playbackSecondaryAction.apply {
|
binding.playbackSecondaryAction.apply {
|
||||||
setIconResource(R.drawable.ic_skip_next_24)
|
setIconResource(R.drawable.ic_skip_next_24)
|
||||||
contentDescription = getString(R.string.desc_skip_next)
|
contentDescription = getString(R.string.desc_skip_next)
|
||||||
iconTint = context.getAttrColorCompat(R.attr.colorOnSurfaceVariant)
|
iconTint = context.getAttrColorCompat(MR.attr.colorOnSurfaceVariant)
|
||||||
setOnClickListener { playbackModel.next() }
|
setOnClickListener { playbackModel.next() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ActionMode.REPEAT -> {
|
ActionMode.REPEAT -> {
|
||||||
|
logD("Setting up repeat mode action")
|
||||||
binding.playbackSecondaryAction.apply {
|
binding.playbackSecondaryAction.apply {
|
||||||
contentDescription = getString(R.string.desc_change_repeat)
|
contentDescription = getString(R.string.desc_change_repeat)
|
||||||
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
||||||
|
@ -108,6 +112,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ActionMode.SHUFFLE -> {
|
ActionMode.SHUFFLE -> {
|
||||||
|
logD("Setting up shuffle action")
|
||||||
binding.playbackSecondaryAction.apply {
|
binding.playbackSecondaryAction.apply {
|
||||||
setIconResource(R.drawable.sel_shuffle_state_24)
|
setIconResource(R.drawable.sel_shuffle_state_24)
|
||||||
contentDescription = getString(R.string.desc_shuffle)
|
contentDescription = getString(R.string.desc_shuffle)
|
||||||
|
@ -120,14 +125,17 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSong(song: Song?) {
|
private fun updateSong(song: Song?) {
|
||||||
if (song != null) {
|
if (song == null) {
|
||||||
val context = requireContext()
|
// Nothing to do.
|
||||||
val binding = requireBinding()
|
return
|
||||||
binding.playbackCover.bind(song)
|
|
||||||
binding.playbackSong.text = song.name.resolve(context)
|
|
||||||
binding.playbackInfo.text = song.artists.resolveNames(context)
|
|
||||||
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val context = requireContext()
|
||||||
|
val binding = requireBinding()
|
||||||
|
binding.playbackCover.bind(song)
|
||||||
|
binding.playbackSong.text = song.name.resolve(context)
|
||||||
|
binding.playbackInfo.text = song.artists.resolveNames(context)
|
||||||
|
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlaying(isPlaying: Boolean) {
|
private fun updatePlaying(isPlaying: Boolean) {
|
||||||
|
|
|
@ -24,6 +24,7 @@ import android.util.AttributeSet
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import com.google.android.material.R as MR
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
|
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
|
||||||
|
@ -39,7 +40,7 @@ class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: Attr
|
||||||
BaseBottomSheetBehavior<V>(context, attributeSet) {
|
BaseBottomSheetBehavior<V>(context, attributeSet) {
|
||||||
val sheetBackgroundDrawable =
|
val sheetBackgroundDrawable =
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||||
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
|
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
|
||||||
elevation = context.getDimen(R.dimen.elevation_normal)
|
elevation = context.getDimen(R.dimen.elevation_normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,8 @@ import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
import org.oxycblt.auxio.util.share
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
|
||||||
|
@ -141,6 +143,7 @@ class PlaybackPanelFragment :
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.action_open_equalizer -> {
|
R.id.action_open_equalizer -> {
|
||||||
// Launch the system equalizer app, if possible.
|
// Launch the system equalizer app, if possible.
|
||||||
|
logD("Launching equalizer")
|
||||||
val equalizerIntent =
|
val equalizerIntent =
|
||||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
||||||
// Provide audio session ID so the equalizer can show options for this app
|
// Provide audio session ID so the equalizer can show options for this app
|
||||||
|
@ -180,6 +183,10 @@ class PlaybackPanelFragment :
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_share -> {
|
||||||
|
playbackModel.song.value?.let { requireContext().share(it) }
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,6 +202,7 @@ class PlaybackPanelFragment :
|
||||||
|
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
|
logD("Updating song display: $song")
|
||||||
binding.playbackCover.bind(song)
|
binding.playbackCover.bind(song)
|
||||||
binding.playbackSong.text = song.name.resolve(context)
|
binding.playbackSong.text = song.name.resolve(context)
|
||||||
binding.playbackArtist.text = song.artists.resolveNames(context)
|
binding.playbackArtist.text = song.artists.resolveNames(context)
|
||||||
|
@ -228,13 +236,11 @@ class PlaybackPanelFragment :
|
||||||
requireBinding().playbackShuffle.isActivated = isShuffled
|
requireBinding().playbackShuffle.isActivated = isShuffled
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to one of the currently playing [Song]'s Artists. */
|
|
||||||
private fun navigateToCurrentArtist() {
|
private fun navigateToCurrentArtist() {
|
||||||
val song = playbackModel.song.value ?: return
|
val song = playbackModel.song.value ?: return
|
||||||
navModel.exploreNavigateToParentArtist(song)
|
navModel.exploreNavigateToParentArtist(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to the currently playing [Song]'s albums. */
|
|
||||||
private fun navigateToCurrentAlbum() {
|
private fun navigateToCurrentAlbum() {
|
||||||
val song = playbackModel.song.value ?: return
|
val song = playbackModel.song.value ?: return
|
||||||
navModel.exploreNavigateTo(song.album)
|
navModel.exploreNavigateTo(song.album)
|
||||||
|
|
|
@ -198,8 +198,14 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont
|
||||||
when (key) {
|
when (key) {
|
||||||
getString(R.string.set_key_replay_gain),
|
getString(R.string.set_key_replay_gain),
|
||||||
getString(R.string.set_key_pre_amp_with),
|
getString(R.string.set_key_pre_amp_with),
|
||||||
getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged()
|
getString(R.string.set_key_pre_amp_without) -> {
|
||||||
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
|
logD("Dispatching ReplayGain setting change")
|
||||||
|
listener.onReplayGainSettingsChanged()
|
||||||
|
}
|
||||||
|
getString(R.string.set_key_notif_action) -> {
|
||||||
|
logD("Dispatching notification setting change")
|
||||||
|
listener.onNotificationActionChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue