Compare commits

..

1 commit
dev ... music2

Author SHA1 Message Date
Alexander Capehart
a784f73c5e
in-progress interpreter refactor
Will force-rewrite at several points.
2024-11-09 20:06:53 -07:00
430 changed files with 12130 additions and 14783 deletions

View file

@ -34,7 +34,6 @@ body:
attributes:
label: What android version do you use?
options:
- Android 15
- Android 14
- Android 13
- Android 12L

View file

@ -25,10 +25,8 @@ jobs:
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Check formatting with spotless
run: ./gradlew spotlessCheck
- name: Test musikr with Gradle
run: ./gradlew musikr:testDebug
- name: Test app with Gradle
run: ./gradlew app:testDebug
- name: Build debug APK with Gradle
run: ./gradlew app:packageDebug
- name: Upload debug APK artifact

2
.gitignore vendored
View file

@ -14,5 +14,3 @@ captures/
*.iml
.cxx
.kotlin
.aider*
.env

5
.gitmodules vendored
View file

@ -1,8 +1,3 @@
[submodule "media"]
path = media
url = https://github.com/OxygenCobalt/media.git
[submodule "musikr/src/main/cpp/taglib"]
path = musikr/src/main/cpp/taglib
url = https://github.com/taglib/taglib.git
tag = ee1931b

View file

@ -1,100 +1,30 @@
# Changelog
## 4.0.3
#### What's Improved
- Improved music loader pipeline efficiency
- Made cover.png support more flexible
- Albums with the same name but different album artists are now split
if fully tagged with album artists
#### What's Fixed
- Possibly fixed cache failures on large libraries
- Possibly fixed playback state saving failing on some devices
- Fixed issue where artists w/o songs would not have a cover
- Fixed music not being reloaded when music locations changed
- Fixed tasker media control not working
- Fixed tasker playback start command never finishing
#### Dev/Meta
- Removed useless storage permissions
- Internal cleanup/simplification of musikr API
- Removed unused resources
#### What's Fixed
## 4.0.2
#### What's New
- Added back in support for cover art from cover.png/cover.jpg
- Added "As is" cover art setting
- Option to include hidden files or not (off by default)
#### What's Improved
- Reduced elevation contrast in black theme
#### What's Fixed
- Fixed incorrect extension stripping on some files
- Fixed various errors in new branding
- Fixed MTE segfault from improper string handling
#### What's Changed
- Hidden files no longer loaded by default
## 4.0.1
#### What's Fixed
- Fixed music loading hanging on files without tags
- Fixed playlists being destroyed in poorly tagged libraries
## 4.0.0
#### What's New
- A total user interface refresh based on the latest Material Design specs
- New theme palettes
- Improved designs for playback and detail views
- New app branding and icon
- Refreshed round mode
- Less intrusive music loading indicators
- **Musikr**, a brand new music loading system
- Directly accesses user files rather than unreliable media database
- Uses faster and more capable native tag parsing
- Stores cover data on-device for fast and high-quality access
- New interpretation system with many quality-of-life improvements
- Android 15 support
- New app branding and icon
- Refreshed playback design
- Live widget preview on Android 15+
- Added GitHub/email feedback forms to about page
#### What's Improved
- Initial music loading is signifigantly faster and less resource intensive
- Album grouping no longer done with artist
- Album grouping no longer done with artist in mind by default
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
- M3U playlist file name is now proposed if one cannot be found within the file
- Duration is now parsed from certain files that previously could not be parsed
- ID3v2 tags are now parsed from WAV files
- NN/TT tracks/discs are now handled in Vorbis
- Music library will is less likely to fail to respond to updates
- Hidden audio files can now be loaded
- Sorting songs by date now uses songs date first, before the earliest album date
- Added working layouts for small split-screen form factors
- Added fast scrolling in detail views
- Added ability to make issues and make feedback e-mails in-app
#### What's Fixed
- Music loader no longer spawns thousands of threads when scanning
- Excessive CPU no longer spent showing music loading process
- Fixed playback sheet flickering on warm start
- No longer possible to save a sort with no direction specified
- Fixed inconsistent corner radii in widget
- Possibly fixed foreground start music loading failures
- Fixed playlist view not exiting on deletion
#### What's Changed
- Date added is now local to when the app discovers the file and will not
persist long-term
- Songs with no album are now "Unknown album" rather than folder name
- Tab layout no longer changes depending on device configuration
- Round mode is now on by default
#### Dev/Meta
- No longer using custom logging setup
- Music loading split off into separate musikr module
## 3.6.3

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.6.3">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.6.3&color=64B5F6&style=flat">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,12 +15,7 @@
</p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
<p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
<a href="https://accrescent.app/app/org.oxycblt.auxio">
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
</a>
</p>
<p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
</p>
@ -33,12 +28,14 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
## Screenshots
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png" width=200>
</p>
@ -64,39 +61,29 @@ precise/original dates, sort tags, and more
- Headset autoplay
- Stylish widgets that automatically adapt to their size
- Completely private and offline
- No rounded album covers (if you want them)
- No rounded album covers (by default)
## Permissions
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
## Donate
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
<p align="center"><b>$16/month supporters:</b></p>
<p align="center">
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
<br/>
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
</p>
<p align="center"><b>$8/month supporters:</b></p>
<p align="center">
<a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a>
<a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=50 /></a>
</p>
## Building
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata
parsing. This adds some caveats to the build process:
Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
1. `cmake` and `ninja-build` must be installed before building the project.
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code.

View file

View file

@ -2,6 +2,7 @@ plugins {
id "com.android.application"
id "kotlin-android"
id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize"
id "dagger.hilt.android.plugin"
id "kotlin-kapt"
@ -11,18 +12,20 @@ plugins {
android {
compileSdk 35
// Auxio implicitly depends on the native modules, explicitly specify it
// here so the libraries are still stripped.
ndkVersion ndk_version
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
// it here so that binary stripping will work.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified
ndkVersion "26.3.11579264"
namespace "org.oxycblt.auxio"
defaultConfig {
applicationId namespace
versionName "4.0.4"
versionCode 63
versionName "3.6.3"
versionCode 53
minSdk min_sdk
targetSdk target_sdk
minSdk 24
targetSdk 35
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@ -77,13 +80,14 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
def coroutines_version = '1.7.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT ---
// General
implementation "androidx.core:core-ktx:$core_version"
implementation "androidx.core:core-ktx:1.15.0"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3"
// noinspection GradleDependency
@ -121,26 +125,20 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.1"
// Database
def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// Build
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugaring_version"
// --- SECOND PARTY ---
// Musikr
implementation project(":musikr")
// --- THIRD PARTY ---
// Exoplayer (Vendored)
implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.3"
// Image loading
implementation 'io.coil-kt.coil3:coil-core:3.0.2'
implementation 'io.coil-kt:coil-base:2.4.0'
// Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
@ -164,4 +162,25 @@ dependencies {
// Fuzzy search
implementation 'org.apache.commons:commons-text:1.9'
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7"
testImplementation "org.robolectric:robolectric:4.11"
testImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
spotless {
kotlin {
target "src/**/*.kt"
ktfmt().dropboxStyle()
licenseHeaderFile("NOTICE")
}
}
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
}

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="info_app_name" translatable="false">Auxio Debug</string>
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
</resources>

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Android 13 uses READ_MEDIA_AUDIO instead of READ_EXTERNAL_STORAGE -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@ -97,15 +100,6 @@
</intent-filter>
</service>
<!--
Expose Auxio's cover data to the android system
-->
<provider
android:name=".image.CoverProvider"
android:authorities="@string/pkg_authority_cover"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<!--
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1309,6 +1309,7 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
+ " should not be set externally.");
}
if (!hideable && state == STATE_HIDDEN) {
Log.w(TAG, "Cannot set state: " + state);
return;
}
final int finalState;
@ -1632,13 +1633,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return;
}
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
boolean canActuallyHide = hideable && isHideableWhenDragging();
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
// If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet.
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED);
setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
return;
}
if (canActuallyHide) {
if (hideable && isHideableWhenDragging()) {
bottomContainerBackHelper.finishBackProgressNotPersistent(
backEvent,
new AnimatorListenerAdapter() {

View file

@ -36,7 +36,6 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import timber.log.Timber
@AndroidEntryPoint
class AuxioService :
@ -54,30 +53,24 @@ class AuxioService :
musicFragment = musicFragmentFactory.create(this, this, this)
sessionToken = playbackFragment.attach()
musicFragment.attach()
Timber.d("Service Created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here.
super.onStartCommand(intent, flags, startId)
onHandleForeground(intent)
// If we die we want to not restart, we will immediately try to foreground in and just
// fail to start again since the activity will be dead too. This is not the semantically
// "correct" flag (normally you want START_STICKY for playback) but we need this to avoid
// weird foreground errors.
return START_NOT_STICKY
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent): IBinder? {
val binder = super.onBind(intent)
onHandleForeground(intent)
return binder
return super.onBind(intent)
}
private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
musicFragment.start()
playbackFragment.start(intent)
playbackFragment.start(startId)
}
override fun onTaskRemoved(rootIntent: Intent?) {
@ -141,7 +134,6 @@ class AuxioService :
}
// Nothing changed, but don't show anything music related since we can always
// index during playback.
isForeground = true
} else {
musicFragment.createNotification {
if (it != null) {

View file

@ -65,8 +65,6 @@ object IntegerTable {
const val START_ID_ACTIVITY = 0xA050
/** Tasker AuxioService Start ID */
const val START_ID_TASKER = 0xA051
/** MediaButtonReceiver AuxioService Start ID */
const val START_ID_MEDIA_BUTTON = 0xA052
/** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */
@ -125,10 +123,10 @@ object IntegerTable {
const val ACTION_MODE_SHUFFLE = 0xA11B
/** CoverMode.Off */
const val COVER_MODE_OFF = 0xA11C
/** CoverMode.Balanced */
const val COVER_MODE_BALANCED = 0xA11D
/** CoverMode.MediaStore */
const val COVER_MODE_MEDIA_STORE = 0xA11D
/** CoverMode.Quality */
const val COVER_MODE_HIGH_QUALITY = 0xA11E
const val COVER_MODE_QUALITY = 0xA11E
/** PlaySong.FromAll */
const val PLAY_SONG_FROM_ALL = 0xA11F
/** PlaySong.FromAlbum */
@ -141,8 +139,4 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124
/** CoverMode.SaveSpace */
const val COVER_MODE_SAVE_SPACE = 0xA125
/** CoverMode.AsIs */
const val COVER_MODE_AS_IS = 0xA126
}

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio
import android.animation.ValueAnimator
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewTreeObserver
@ -26,7 +27,6 @@ import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
@ -50,8 +50,10 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
@ -68,8 +70,6 @@ import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -257,9 +257,9 @@ class MainFragment :
}
override fun onPreDraw(): Boolean {
// This is where I shove literally all the UI logic that won't behave any callback
// or "normal" method I've tried. Surely running this on every frame will actually cause
// it to work properly!
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
// sheets continually getting stuck. I need something with even more frequent updates,
// or otherwise bottom sheets get stuck.
// We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should
@ -367,10 +367,6 @@ class MainFragment :
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled()
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
binding.mainFabContainer.isVisible =
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
return true
}
@ -408,6 +404,9 @@ class MainFragment :
}
private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
if (state is IndexingState.Completed && state.error == null) {
L.d("Received ok response")
val binding = requireBinding()
@ -513,6 +512,8 @@ class MainFragment :
}
}
private var scrimAnimator: ValueAnimator? = null
private fun updateSpeedDial(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)

View file

@ -22,7 +22,6 @@ import android.os.Bundle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
@ -30,9 +29,12 @@ import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs
@ -42,10 +44,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -117,7 +115,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
binding.detailToolbarTitle.text = name
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = album.releaseType.resolve(context)
binding.detailType.text = getString(album.releaseType.stringRes)
binding.detailName.text = name
// Artist name maps to the subhead text
binding.detailSubhead.apply {
@ -133,7 +131,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
@ -142,15 +140,9 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}
@ -299,11 +291,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
// collapsed appbar), so we need to collapse the appbar if that's the case.
binding.detailAppbar.setExpanded(false)
if (!binding.detailRecycler.canScroll()) {
// Don't scroll if the RecyclerView goes off screen. If we go anyway, overscroll
// kicks in and creates a weird bounce effect.
return
}
binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end.
@ -329,6 +316,4 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
}
}
}
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
}

View file

@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect
@ -40,11 +44,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -164,15 +163,9 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}

View file

@ -35,13 +35,13 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
abstract class DetailFragment<P : MusicParent, C : Music> :
ListFragment<C, FragmentDetailBinding>(),
@ -123,9 +123,6 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
val detailContent = binding.detailToolbarContent
detailContent.alpha = inRatio
detailContent.translationY = spacingSmall * (1 - inRatio)
// Enable fast scrolling once fully collapsed
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
}
abstract fun onOpenParentMenu()

View file

@ -23,17 +23,17 @@ import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort
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.MusicType
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.ReleaseType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.ReleaseType
import timber.log.Timber as L
interface DetailGenerator {
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
}
override fun album(uid: Music.UID): Detail<Album>? {
val album = musicRepository.library?.findAlbum(uid) ?: return null
val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null
val songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc }
val section =
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
}
override fun artist(uid: Music.UID): Detail<Artist>? {
val artist = musicRepository.library?.findArtist(uid) ?: return null
val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null
val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into detail sections
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
}
override fun genre(uid: Music.UID): Detail<Genre>? {
val genre = musicRepository.library?.findGenre(uid) ?: return null
val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
return Detail(genre, listOf(artists, songs))
}
override fun playlist(uid: Music.UID): Detail<Playlist>? {
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null
if (playlist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(playlist.songs)
return Detail(playlist, listOf(songs))

View file

@ -22,14 +22,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.DiscDivider
import org.oxycblt.auxio.detail.list.DiscHeader
import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item
@ -38,20 +40,21 @@ import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
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.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -66,11 +69,11 @@ class DetailViewModel
constructor(
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent<Show>()
/**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
*/
@ -79,34 +82,30 @@ constructor(
// --- SONG ---
private val _currentSong = MutableStateFlow<Song?>(null)
private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?>
get() = _currentSong
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
/** The current properties of [currentSong]. Empty if nothing to show. */
val currentSongProperties: StateFlow<List<SongProperty>>
get() = _currentSongProperties
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
// --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?>
get() = _currentAlbum
private val _albumSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */
val albumSongList: StateFlow<List<Item>>
get() = _albumSongList
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumSongList] in the UI. */
val albumSongInstructions: Event<UpdateInstructions>
get() = _albumSongInstructions
@ -122,18 +121,15 @@ constructor(
// --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?>
get() = _currentArtist
private val _artistSongList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */
val artistSongList: StateFlow<List<Item>> = _artistSongList
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */
val artistSongInstructions: Event<UpdateInstructions>
get() = _artistSongInstructions
@ -149,18 +145,15 @@ constructor(
// --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?>
get() = _currentGenre
private val _genreSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */
val genreSongList: StateFlow<List<Item>> = _genreSongList
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */
val genreSongInstructions: Event<UpdateInstructions>
get() = _genreSongInstructions
@ -176,24 +169,20 @@ constructor(
// --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist
private val _playlistSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistSongList] in the UI. */
val playlistSongInstructions: Event<UpdateInstructions>
get() = _playlistSongInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/**
* The new playlist songs created during the current editing session. Null if no editing session
* is occurring.
@ -319,14 +308,14 @@ constructor(
}
/**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
* the new [Song].
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSong(uid: Music.UID) {
L.d("Opening song $uid")
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
if (_currentSong.value == null) {
L.w("Given song UID was invalid")
}
@ -522,32 +511,16 @@ constructor(
}
private fun refreshAudioInfo(song: Song) {
_currentSongProperties.value = buildList {
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
}
song.disc?.let {
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
}
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
add(
SongProperty(
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
}
L.d("Refreshing audio info")
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_songAudioProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = audioPropertiesFactory.extract(song)
yield()
L.d("Updating audio info to $info")
_songAudioProperties.value = info
}
}

View file

@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
@ -39,11 +43,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -133,15 +132,9 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}

View file

@ -35,9 +35,13 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
@ -48,11 +52,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**
@ -232,24 +231,12 @@ class PlaylistDetailFragment :
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailToolbarPlay.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailShuffleButton?.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailToolbarShuffle.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}

View file

@ -18,7 +18,9 @@
package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
@ -30,9 +32,16 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.util.concatLocalized
import timber.log.Timber as L
/**
@ -62,19 +71,74 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSong(args.songUid)
detailModel.toShow.consume()
collectImmediately(detailModel.currentSong, ::updateSong)
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
}
private fun updateSong(song: Song?) {
L.d("No song to show, navigating away")
private fun updateSong(song: Song?, info: AudioProperties?) {
if (song == null) {
L.d("No song to show, navigating away")
findNavController().navigateUp()
return
}
if (info != null) {
val context = requireContext()
detailAdapter.update(
buildList {
add(SongProperty(R.string.lbl_name, song.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_genres, song.genres.resolveNames(context)))
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
}
song.disc?.let {
val formattedNumber = getString(R.string.fmt_number, it.number)
val zipped =
if (it.name != null) {
getString(R.string.fmt_zipped_names, formattedNumber, it.name)
} else {
formattedNumber
}
add(SongProperty(R.string.lbl_disc, zipped))
}
add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
info.resolvedMimeType.resolveName(context)?.let {
add(SongProperty(R.string.lbl_format, it))
}
add(
SongProperty(
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
info.bitrateKbps?.let {
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
}
info.sampleRateHz?.let {
add(
SongProperty(
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
}
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
}
},
UpdateInstructions.Replace(0))
}
}
private fun updateSongProperties(songProperties: List<SongProperty>) {
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
private fun <T : Music> T.zipName(context: Context): String {
val name = name
return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
name.resolve(context)
}
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }
}

View file

@ -25,10 +25,9 @@ import org.oxycblt.auxio.list.ClickableListListener
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.music.resolve
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist
/**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with

View file

@ -23,12 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import timber.log.Timber as L
/**
@ -56,9 +56,9 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val library = musicRepository.library ?: return
val deviceLibrary = musicRepository.deviceLibrary ?: return
// Need to sanitize different items depending on the current set of choices.
_artistChoices.value = _artistChoices.value?.sanitize(library)
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
L.d("Updated artist choices: ${_artistChoices.value}")
}
@ -98,15 +98,16 @@ sealed interface ArtistShowChoices {
val uid: Music.UID
/** The current [Artist] choices. */
val choices: List<Artist>
/** Sanitize this instance with a [Library]. */
fun sanitize(newLibrary: Library): ArtistShowChoices?
/** Sanitize this instance with a [DeviceLibrary]. */
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
class FromSong(val song: Song) : ArtistShowChoices {
override val uid = song.uid
override val choices = song.artists
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findSong(uid)?.let { FromSong(it) }
}
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
@ -114,7 +115,7 @@ sealed interface ArtistShowChoices {
override val uid = album.uid
override val choices = album.artists
override fun sanitize(newLibrary: Library) =
override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
}
}

View file

@ -32,9 +32,9 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L
/**

View file

@ -35,14 +35,14 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@ -121,7 +121,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
*/
fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner
binding.discNumber.text = disc.resolve(binding.context)
binding.discNumber.text = disc.resolveNumber(binding.context)
binding.discName.apply {
text = disc?.name
isGone = disc?.name == null

View file

@ -29,13 +29,12 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
@ -105,7 +104,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
album.dates?.resolveDate(binding.context)
?: binding.context.getString(R.string.def_date)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {

View file

@ -35,9 +35,9 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
/**
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the

View file

@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.

View file

@ -40,13 +40,12 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**

View file

@ -18,26 +18,17 @@
package org.oxycblt.auxio.detail.list
import android.text.format.Formatter
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item
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.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date
/**
* An adapter for [SongProperty] instances.
@ -62,31 +53,7 @@ class SongPropertyAdapter :
* @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt)
*/
data class SongProperty(@StringRes val name: Int, val value: Value) {
sealed interface Value {
data class MusicName(val music: Music) : Value
data class MusicNames(val name: List<Music>) : Value
data class Number(val value: Int, val subtitle: String?) : Value
data class ItemDate(val date: Date) : Value
data class ItemPath(val path: Path) : Value
data class Size(val sizeBytes: Long) : Value
data class Duration(val durationMs: Long) : Value
data class ItemFormat(val format: Format) : Value
data class Bitrate(val kbps: Int) : Value
data class SampleRate(val hz: Int) : Value
data class Decibels(val value: Float) : Value
}
}
data class SongProperty(@StringRes val name: Int, val value: String) : Item
/**
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
@ -98,58 +65,7 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
fun bind(property: SongProperty) {
val context = binding.context
binding.propertyName.hint = context.getString(property.name)
when (property.value) {
is SongProperty.Value.MusicName -> {
val music = property.value.music
binding.propertyValue.setText(music.name.resolve(context))
}
is SongProperty.Value.MusicNames -> {
val names = property.value.name.resolveNames(context)
binding.propertyValue.setText(names)
}
is SongProperty.Value.Number -> {
val value = context.getString(R.string.fmt_number, property.value.value)
val subtitle = property.value.subtitle
binding.propertyValue.setText(
if (subtitle != null) {
context.getString(R.string.fmt_zipped_names, value, subtitle)
} else {
value
})
}
is SongProperty.Value.ItemDate -> {
val date = property.value.date
binding.propertyValue.setText(date.resolve(context))
}
is SongProperty.Value.ItemPath -> {
val path = property.value.path
binding.propertyValue.setText(path.resolve(context))
}
is SongProperty.Value.Size -> {
val size = property.value.sizeBytes
binding.propertyValue.setText(Formatter.formatFileSize(context, size))
}
is SongProperty.Value.Duration -> {
val duration = property.value.durationMs
binding.propertyValue.setText(duration.formatDurationMs(true))
}
is SongProperty.Value.ItemFormat -> {
val format = property.value.format
binding.propertyValue.setText(format.resolve(context))
}
is SongProperty.Value.Bitrate -> {
val kbps = property.value.kbps
binding.propertyValue.setText(context.getString(R.string.fmt_bitrate, kbps))
}
is SongProperty.Value.SampleRate -> {
val hz = property.value.hz
binding.propertyValue.setText(context.getString(R.string.fmt_sample_rate, hz))
}
is SongProperty.Value.Decibels -> {
val value = property.value.value
binding.propertyValue.setText(value.formatDb(context))
}
}
binding.propertyValue.setText(property.value)
}
companion object {

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import timber.log.Timber as L
/**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L
/**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import timber.log.Timber as L
/**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L
/**

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui
package org.oxycblt.auxio.home
import android.content.Context
import android.util.AttributeSet
@ -40,6 +40,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Prevent excessive layouts by using translation instead of padding.
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets
}

View file

@ -24,11 +24,9 @@ import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.openInBrowser
@ -44,12 +42,10 @@ import org.oxycblt.auxio.util.showToast
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
private val args: ErrorDetailsDialogArgs by navArgs()
private var clipboardManager: ClipboardManager? = null
private val musicModel: MusicViewModel by viewModels()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_error_info)
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
.setPositiveButton(R.string.lbl_report) { _, _ ->
requireContext().openInBrowser(LINK_ISSUES)
}

View file

@ -22,10 +22,10 @@ import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
@ -37,10 +37,12 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
@ -51,27 +53,31 @@ 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.NamedTabStrategy
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectionFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**
@ -172,7 +178,6 @@ class HomeFragment :
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu)
@ -266,7 +271,9 @@ class HomeFragment :
// Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator(
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
binding.homeTabs,
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
.attach()
}
@ -297,49 +304,98 @@ class HomeFragment :
homeModel.recreateTabs.consume()
}
private fun handleChooseFolders(unit: Unit?) {
if (unit == null) {
return
}
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
homeModel.chooseMusicLocations.consume()
}
private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
is IndexingState.Completed -> {
binding.homeIndexingContainer.isInvisible = state.error == null
binding.homeIndexingProgress.isInvisible = state.error != null
binding.homeIndexingError.isInvisible = state.error == null
if (state.error != null) {
binding.homeIndexingContainer.setOnClickListener {
findNavController()
.navigateSafe(HomeFragmentDirections.reportError(state.error))
}
} else {
binding.homeIndexingContainer.setOnClickListener(null)
}
}
is IndexingState.Indexing -> {
binding.homeIndexingContainer.isInvisible = false
binding.homeIndexingProgress.apply {
isInvisible = false
when (state.progress) {
is IndexingProgress.Songs -> {
isIndeterminate = false
progress = state.progress.loaded
max = state.progress.explored
}
is IndexingProgress.Indeterminate -> {
isIndeterminate = true
}
}
}
binding.homeIndexingError.isInvisible = true
}
is IndexingState.Completed -> setupCompleteState(binding, state.error)
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
null -> {
binding.homeIndexingContainer.isInvisible = true
L.d("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
}
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
L.d("Received ok response")
binding.homeIndexingContainer.visibility = View.INVISIBLE
return
}
L.d("Received non-ok response")
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
binding.homeIndexingActions.visibility = View.VISIBLE
when (error) {
is NoAudioPermissionException -> {
L.d("Showing permission prompt")
binding.homeIndexingStatus.setText(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingTry.apply {
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(PERMISSION_READ_AUDIO)
}
}
binding.homeIndexingMore.visibility = View.GONE
}
is NoMusicException -> {
L.d("Showing no music error")
binding.homeIndexingStatus.setText(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
binding.homeIndexingMore.visibility = View.GONE
}
else -> {
L.d("Showing generic error")
binding.homeIndexingStatus.setText(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
binding.homeIndexingMore.apply {
visibility = View.VISIBLE
setOnClickListener {
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
}
}
}
}
}
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingActions.visibility = View.INVISIBLE
binding.homeIndexingStatus.setText(R.string.lng_indexing)
when (progress) {
is IndexingProgress.Indeterminate -> {
// In a query/initialization state, show a generic loading status.
binding.homeIndexingProgress.isIndeterminate = true
}
is IndexingProgress.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingProgress.apply {
isIndeterminate = false
max = progress.total
this.progress = progress.current
}
}
}
}
@ -508,5 +564,11 @@ class HomeFragment :
private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
}
}

View file

@ -22,13 +22,13 @@ import javax.inject.Inject
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import timber.log.Timber as L
interface HomeGenerator {
@ -36,8 +36,6 @@ interface HomeGenerator {
fun release()
fun empty(): Boolean
fun songs(): List<Song>
fun albums(): List<Album>
@ -51,8 +49,6 @@ interface HomeGenerator {
fun tabs(): List<MusicType>
interface Invalidator {
fun invalidateEmpty() {}
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
fun invalidateTabs()
@ -123,10 +119,8 @@ private class HomeGeneratorImpl(
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
invalidator.invalidateEmpty()
val library = musicRepository.library
if (changes.deviceLibrary && library != null) {
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
L.d("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
@ -136,7 +130,8 @@ private class HomeGeneratorImpl(
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
}
if (changes.userLibrary && library != null) {
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
L.d("Refreshing playlists")
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
}
@ -148,16 +143,15 @@ private class HomeGeneratorImpl(
homeSettings.unregisterListener(this)
}
override fun empty() = musicRepository.library?.empty() ?: true
override fun songs() =
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
override fun albums() =
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
?: emptyList()
override fun artists() =
musicRepository.library?.let { deviceLibrary ->
musicRepository.deviceLibrary?.let { deviceLibrary ->
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
if (homeSettings.shouldHideCollaborators) {
sorted.filter { it.explicitAlbums.isNotEmpty() }
@ -167,10 +161,11 @@ private class HomeGeneratorImpl(
} ?: emptyList()
override fun genres() =
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
?: emptyList()
override fun playlists() =
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
?: emptyList()
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }

View file

@ -27,16 +27,16 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -120,10 +120,6 @@ constructor(
val playlistList: StateFlow<List<Playlist>>
get() = _playlistList
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean>
get() = _empty
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genreList] in the UI. */
val playlistInstructions: Event<UpdateInstructions>
@ -163,10 +159,6 @@ constructor(
val showOuter: Event<Outer>
get() = _showOuter
private val _chooseMusicLocations = MutableEvent<Unit>()
val chooseMusicLocations: Event<Unit>
get() = _chooseMusicLocations
init {
homeGenerator.attach()
}
@ -176,10 +168,6 @@ constructor(
homeGenerator.release()
}
override fun invalidateEmpty() {
_empty.value = homeGenerator.empty()
}
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) {
MusicType.SONGS -> {
@ -275,10 +263,6 @@ constructor(
_isFastScrolling.value = isFastScrolling
}
fun startChooseMusicLocations() {
_chooseMusicLocations.put(Unit)
}
fun showSettings() {
_showOuter.put(Outer.Settings)
}

View file

@ -190,8 +190,6 @@ class ThemedSpeedDialView : SpeedDialView {
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor)
}
// Fix default margins added by library
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
}
private fun Int.withModulatedAlpha(
@ -232,24 +230,13 @@ class ThemedSpeedDialView : SpeedDialView {
return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply {
updateLayoutParams<MarginLayoutParams> {
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
} else {
setMargins(0, 0, rightMargin, 0)
}
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
setMargins(horizontalMargin, 0, horizontalMargin, 0)
}
useCompatPadding = false
}
labelBackground.apply {
updateLayoutParams<MarginLayoutParams> {
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
}
}
useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background =

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
@ -38,16 +36,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Album]s.
@ -82,16 +79,7 @@ class AlbumListFragment :
listener = this@AlbumListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_album_48)
contentDescription = getString(R.string.lbl_albums)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_albums)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.albumList, ::updateAlbums)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -111,10 +99,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> album.name.thumb()
is Sort.Mode.ByName -> album.name.thumb
// By Artist -> Use name of first artist
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)
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
@ -127,7 +115,7 @@ class AlbumListFragment :
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.addedMs
val dateAddedMillis = album.dateAdded.secsToMs()
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,
@ -159,14 +147,6 @@ class AlbumListFragment :
albumAdapter.update(albums, homeModel.albumInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
@ -36,16 +34,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
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.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Artist]s.
@ -77,16 +74,7 @@ class ArtistListFragment :
listener = this@ArtistListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_artist_48)
contentDescription = getString(R.string.lbl_artists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_artists)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.artistList, ::updateArtists)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -106,7 +94,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb()
is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -135,14 +123,6 @@ class ArtistListFragment :
artistAdapter.update(artists, homeModel.artistInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
@ -36,15 +34,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
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.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Genre]s.
@ -76,16 +73,7 @@ class GenreListFragment :
listener = this@GenreListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_genre_48)
contentDescription = getString(R.string.lbl_genres)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_genres)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.genreList, ::updateGenres)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -105,7 +93,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb()
is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -134,14 +122,6 @@ class GenreListFragment :
genreAdapter.update(genres, homeModel.genreInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* ListUtil.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.home.list
import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.tag.Name
fun Name.thumb() =
when (this) {
is Name.Known ->
tokens.firstOrNull()?.let {
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
}
is Name.Unknown -> "?"
}

View file

@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
@ -35,15 +33,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
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.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Playlist]s.
@ -74,18 +71,7 @@ class PlaylistListFragment :
listener = this@PlaylistListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_playlist_48)
contentDescription = getString(R.string.lbl_playlists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_playlists)
collectImmediately(homeModel.playlistList, ::updatePlaylists)
collectImmediately(
homeModel.empty,
homeModel.playlistList,
musicModel.indexingState,
::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -105,7 +91,7 @@ class PlaylistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb()
is Sort.Mode.ByName -> playlist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
@ -134,26 +120,6 @@ class PlaylistListFragment :
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
}
private fun updateNoMusicIndicator(
empty: Boolean,
playlists: List<Playlist>,
indexingState: IndexingState?
) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty && playlists.isNotEmpty()
if (!empty && playlists.isEmpty()) {
binding.homeNoMusicAction.isVisible = true
binding.homeNoMusicAction.text = getString(R.string.lbl_new_playlist)
binding.homeNoMusicAction.setOnClickListener { musicModel.createPlaylist() }
} else {
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
binding.homeNoMusicAction.text = getString(R.string.lbl_music_sources)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
}
}
private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
@ -37,15 +35,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Song]s.
@ -62,7 +59,6 @@ class SongListFragment :
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb)
@ -80,16 +76,7 @@ class SongListFragment :
listener = this@SongListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_song_48)
contentDescription = getString(R.string.lbl_songs)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_songs)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.songList, ::updateSongs)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -111,23 +98,23 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) {
// Name -> Use name
is Sort.Mode.ByName -> song.name.thumb()
is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb()
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb()
is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolve(requireContext())
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.addedMs
val dateAddedMillis = song.dateAdded.secsToMs()
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,
@ -159,14 +146,6 @@ class SongListFragment :
songAdapter.update(songs, homeModel.songInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2022 Auxio Project
* AdaptiveTabStrategy.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.home.tabs
import android.content.Context
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicType
/**
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
* depending on the screen configuration.
*
* @param context [Context] required to obtain window information
* @param tabs Current tab configuration from settings
* @author Alexander Capehart (OxygenCobalt)
*/
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
TabLayoutMediator.TabConfigurationStrategy {
private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val homeTab = tabs[position]
val icon =
when (homeTab) {
MusicType.SONGS -> R.drawable.ic_song_24
MusicType.ALBUMS -> R.drawable.ic_album_24
MusicType.ARTISTS -> R.drawable.ic_artist_24
MusicType.GENRES -> R.drawable.ic_genre_24
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
}
// Use expected sw* size thresholds when choosing a configuration.
when {
// On small screens, only display an icon.
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
// On large screens, display an icon and text.
width < 600 -> tab.setText(homeTab.nameRes)
// On medium-size screens, display text.
else -> tab.setIcon(icon).setText(homeTab.nameRes)
}
}
}

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* NamedTabStrategy.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.home.tabs
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.oxycblt.auxio.music.MusicType
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(homeTabs[position].nameRes)
}
}

View file

@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
import android.content.Context
import android.graphics.Bitmap
import coil3.ImageLoader
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.size.Size
import coil3.toBitmap
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.musikr.Song
import org.oxycblt.auxio.music.Song
/**
* A utility to provide bitmaps in a race-less manner.
@ -94,7 +94,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(song.cover)
.data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL))
.target(

View file

@ -26,11 +26,12 @@ import org.oxycblt.auxio.IntegerTable
* @author Alexander Capehart (OxygenCobalt)
*/
enum class CoverMode {
/** Do not load album covers ("Off"). */
OFF,
SAVE_SPACE,
BALANCED,
HIGH_QUALITY,
AS_IS;
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
MEDIA_STORE,
/** Load high-quality covers directly from music files ("Quality"). */
QUALITY;
/**
* The integer representation of this instance.
@ -41,10 +42,8 @@ enum class CoverMode {
get() =
when (this) {
OFF -> IntegerTable.COVER_MODE_OFF
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
BALANCED -> IntegerTable.COVER_MODE_BALANCED
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
AS_IS -> IntegerTable.COVER_MODE_AS_IS
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
QUALITY -> IntegerTable.COVER_MODE_QUALITY
}
companion object {
@ -58,10 +57,8 @@ enum class CoverMode {
fun fromIntCode(intCode: Int) =
when (intCode) {
IntegerTable.COVER_MODE_OFF -> OFF
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
IntegerTable.COVER_MODE_BALANCED -> BALANCED
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
IntegerTable.COVER_MODE_AS_IS -> AS_IS
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
IntegerTable.COVER_MODE_QUALITY -> QUALITY
else -> null
}
}

View file

@ -1,86 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* CoverProvider.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.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.runBlocking
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.musikr.covers.CoverResult
class CoverProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r" || uriMatcher.match(uri) != 1) {
return null
}
val id = uri.lastPathSegment ?: return null
return runBlocking {
when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) {
is CoverResult.Hit -> result.cover.fd()
else -> null
}
}
}
override fun getType(uri: Uri): String {
check(uriMatcher.match(uri) == 1) { "Unknown URI: $uri" }
return "image/*"
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor = throw UnsupportedOperationException()
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
companion object {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.image.CoverProvider"
private const val IMAGES_PATH = "covers"
private val uriMatcher =
UriMatcher(UriMatcher.NO_MATCH).apply { addURI(AUTHORITY, "$IMAGES_PATH/*", 1) }
val CONTENT_URI: Uri =
Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(IMAGES_PATH)
.build()
}
}

View file

@ -37,35 +37,31 @@ import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.isEmpty
import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat
import coil3.ImageLoader
import coil3.asImage
import coil3.request.ImageRequest
import coil3.request.target
import coil3.request.transformations
import coil3.util.CoilUtils
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 com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
import org.oxycblt.auxio.image.coil.SquareCropTransformation
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
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.MaterialFader
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.covers.CoverCollection
/**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
@ -173,7 +169,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.onFinishInflate()
// The image isn't added if other children have populated the body. This is by design.
if (isEmpty()) {
if (childCount == 0) {
addView(image)
}
@ -317,7 +313,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(song: Song) =
bindImpl(
song.cover,
listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24)
@ -328,7 +324,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(album: Album) =
bindImpl(
album.covers,
album.cover.all,
context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24)
@ -339,7 +335,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(artist: Artist) =
bindImpl(
artist.covers,
artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24)
@ -350,7 +346,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) =
bindImpl(
genre.covers,
genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24)
@ -361,7 +357,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(playlist: Playlist) =
bindImpl(
playlist.covers,
playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
@ -373,15 +369,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @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) =
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
bindImpl(Cover.order(songs), desc, errorRes)
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
.data(cover)
.error(
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage())
.data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
.target(image)
val cornersTransformation =
@ -410,7 +404,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Px val iconSize: Int?
) : Drawable() {
init {
// Re-tint the drawable to use the analogous "on surface" color for
// Re-tint the drawable to use the analogous "on surfaceg" color for
// StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
}

View file

@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
get() =
CoverMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.BALANCED
?: CoverMode.MEDIA_STORE
override val forceSquareCovers: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
@ -64,8 +64,8 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.BALANCED
else -> CoverMode.BALANCED
CoverMode.MEDIA_STORE
else -> CoverMode.QUALITY
}
sharedPreferences.edit {
@ -74,24 +74,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
remove(OLD_KEY_QUALITY_COVERS)
}
}
if (sharedPreferences.contains(OLD_KEY_COVER_MODE)) {
L.d("Migrating cover mode setting")
var mode =
CoverMode.fromIntCode(sharedPreferences.getInt(OLD_KEY_COVER_MODE, Int.MIN_VALUE))
?: CoverMode.BALANCED
if (mode == CoverMode.HIGH_QUALITY) {
// High quality now has space characteristics that could be
// undesirable, clamp to balanced.
mode = CoverMode.BALANCED
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
remove(OLD_KEY_COVER_MODE)
}
}
}
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
@ -105,6 +87,5 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
}
}

View file

@ -1,140 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverCollectionFetcher.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.coil
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Canvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.CoverCollection
class CoverCollectionFetcher
private constructor(
private val context: Context,
private val covers: CoverCollection,
private val size: Size,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
// 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) {
// 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) }
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceFetchResult(
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return ImageFetchResult(
image = mosaicBitmap.toDrawable(context.resources).asImage(),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
CoverCollectionFetcher(options.context, data, options.size)
}
}

View file

@ -1,47 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFetcher.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.coil
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import javax.inject.Inject
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.Cover
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
override suspend fun fetch(): FetchResult? {
val stream = cover.open() ?: return null
return SourceFetchResult(
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data)
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* Keyers.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.coil
import coil3.key.Keyer
import coil3.request.Options
import javax.inject.Inject
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
override fun key(data: CoverCollection, options: Options) =
"multi:${data.hashCode()}&${options.size}"
}

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* NullCovers.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.covers
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
override suspend fun cleanup(excluding: Collection<Cover>) {
storage.ls(setOf()).map { storage.rm(it) }
}
}
data object NullCover : Cover {
override val id = "null"
override suspend fun open() = null
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* RevisionedTranscoding.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.covers
import java.util.UUID
import org.oxycblt.musikr.covers.stored.Transcoding
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
override val tag = "_$revision${inner.tag}"
}

View file

@ -1,73 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* SettingCovers.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.covers
import android.content.Context
import android.graphics.Bitmap
import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.chained.ChainedCovers
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.covers.fs.FSCovers
import org.oxycblt.musikr.covers.fs.MutableFSCovers
import org.oxycblt.musikr.covers.stored.Compress
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
import org.oxycblt.musikr.covers.stored.NoTranscoding
import org.oxycblt.musikr.covers.stored.StoredCovers
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object {
suspend fun immutable(context: Context): Covers<FDCover> =
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
}
}
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
SettingCovers {
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
val coverStorage = CoverStorage.at(context.coversDir())
val transcoding =
when (imageSettings.coverMode) {
CoverMode.OFF -> return NullCovers(coverStorage)
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
CoverMode.AS_IS -> NoTranscoding
}
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
val storedCovers =
MutableStoredCovers(
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
val fsCovers = MutableFSCovers(context)
return MutableChainedCovers(storedCovers, fsCovers)
}
}
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2021 Auxio Project
* Components.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 coil.ImageLoader
import coil.fetch.Fetcher
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
override fun key(data: Collection<Cover>, options: Options) =
"${data.map { it.key }.hashCode()}"
}
class CoverFetcher
private constructor(
private val covers: Collection<Cover>,
private val size: Size,
private val coverExtractor: CoverExtractor,
) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data, options.size, coverExtractor)
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 Auxio Project
* Cover.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.net.Uri
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song
sealed interface Cover {
val key: String
val mediaStoreCoverUri: Uri
/**
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
*/
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
Cover {
override val mediaStoreCoverUri = songCoverUri
override val key = perceptualHash
}
/**
* We couldn't find any embedded cover art ourselves, but the android system might have some
* through a cover.jpg file or something similar.
*/
data class External(val albumCoverUri: Uri) : Cover {
override val mediaStoreCoverUri = albumCoverUri
override val key = albumCoverUri.toString()
}
companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs)
.map { it.cover }
.groupBy { it.key }
.entries
.sortedByDescending { it.value.size }
.map { it.value.first() }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
}
}

View file

@ -0,0 +1,253 @@
/*
* Copyright (c) 2023 Auxio Project
* CoverExtractor.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.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.media.MediaMetadataRetriever
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Song
import timber.log.Timber as L
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param covers The [Cover]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 the first four loaded album covers. Otherwise, a
* [SourceResult] of one album cover will be returned.
*/
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val streams = mutableListOf<InputStream>()
for (cover in covers) {
openCoverInputStream(cover)?.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) {
// 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) }
}
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceResult(
source = ImageSource(first.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
// Vorbis picture comments.
val pic: ByteArray?
val type: Int
when (val entry = metadata.get(i)) {
is ApicFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (cover) {
is Cover.Embedded ->
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
is Cover.External -> {
extractMediaStoreCover(cover)
}
}
} catch (e: Exception) {
L.e("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(cover: Cover.Embedded) =
extractExoplayerCover(cover)
?: extractAospMetadataCover(cover)
?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
MediaMetadataRetriever().run {
// This call is time-consuming but it also doesn't seem to hold up the main thread,
// so it's probably fine not to wrap it.rmt
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
@SuppressLint("Recycle")
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
// Coil will recycle this InputStream, so we don't need to worry about it.
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.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 android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
@Suppress("UNUSED")
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import coil3.decode.DataSource
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.transition.CrossfadeDrawable
import coil3.transition.CrossfadeTransition
import coil3.transition.Transition
import coil3.transition.TransitionTarget
import coil.decode.DataSource
import coil.drawable.CrossfadeDrawable
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.transition.CrossfadeTransition
import coil.transition.Transition
import coil.transition.TransitionTarget
/**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* CoilModule.kt is part of Auxio.
* ExtractorModule.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
@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import android.content.Context
import coil3.ImageLoader
import coil3.request.CachePolicy
import coil3.request.transitionFactory
import coil.ImageLoader
import coil.request.CachePolicy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,22 +30,19 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class CoilModule {
class ExtractorModule {
@Singleton
@Provides
fun imageLoader(
@ApplicationContext context: Context,
coverKeyer: CoverKeyer,
coverFactory: CoverFetcher.Factory,
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
keyer: CoverKeyer,
factory: CoverFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
add(coverKeyer)
add(coverFactory)
add(coverCollectionKeyer)
add(coverCollectionFactory)
// Add fetchers for Music components to make them usable with ImageRequest
add(keyer)
add(factory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap
@ -30,16 +30,16 @@ import android.graphics.RectF
import android.graphics.Shader
import androidx.annotation.Px
import androidx.core.graphics.applyCanvas
import coil3.decode.DecodeUtils
import coil3.size.Scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
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's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
* without cropping them.
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
* images without cropping them.
*
* @author Coil Team, Alexander Capehart (OxygenCobalt)
*/
@ -48,7 +48,7 @@ class RoundedRectTransformation(
@Px private val topRight: Float = 0f,
@Px private val bottomLeft: Float = 0f,
@Px private val bottomRight: Float = 0f
) : Transformation() {
) : Transformation {
constructor(@Px radius: Float) : this(radius, radius, radius, radius)

View file

@ -16,13 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import androidx.core.graphics.scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
import kotlin.math.min
/**
@ -31,7 +30,7 @@ import kotlin.math.min
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SquareCropTransformation : Transformation() {
class SquareCropTransformation : Transformation {
override val cacheKey: String
get() = "SquareCropTransformation"
@ -47,7 +46,7 @@ class SquareCropTransformation : Transformation() {
val desiredHeight = size.height.pxOrElse { dstSize }
if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it.
return dst.scale(desiredWidth, desiredHeight)
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
}
return dst
}

View file

@ -22,9 +22,9 @@ 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. */
typealias Item = Any
interface Item
interface Header
interface Header : Item
/**
* A "header" used for delimiting groups of data.
@ -44,7 +44,7 @@ interface PlainHeader : Header {
*/
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
interface Divider<T> {
interface Divider<T> : Item {
val anchor: T?
}

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.musikr.Music
import org.oxycblt.auxio.music.Music
/**
* A Fragment containing a selectable list.

View file

@ -25,17 +25,17 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.menu.Menu
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.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
@ -64,17 +64,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val library = musicRepository.library ?: return
val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
// Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list.
_selected.value =
_selected.value.mapNotNull {
when (it) {
is Song -> library.findSong(it.uid)
is Album -> library.findAlbum(it.uid)
is Artist -> library.findArtist(it.uid)
is Genre -> library.findGenre(it.uid)
is Playlist -> library.findPlaylist(it.uid)
is Song -> deviceLibrary.findSong(it.uid)
is Album -> deviceLibrary.findAlbum(it.uid)
is Artist -> deviceLibrary.findArtist(it.uid)
is Genre -> deviceLibrary.findGenre(it.uid)
is Playlist -> userLibrary.findPlaylist(it.uid)
}
}
}

View file

@ -21,7 +21,7 @@ package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.musikr.Music
import org.oxycblt.auxio.music.Music
import timber.log.Timber as L
/**

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
import android.os.Parcelable
import androidx.annotation.MenuRes
import kotlinx.parcelize.Parcelize
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.playback.PlaySong
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* Command to navigate to a specific menu dialog configuration.

View file

@ -27,18 +27,17 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMenuBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* [MenuDialogFragment] implementation for a [Song].
@ -113,7 +112,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
val context = requireContext()
binding.menuCover.bind(menu.album)
binding.menuType.text = menu.album.releaseType.resolve(context)
binding.menuType.text = getString(menu.album.releaseType.stringRes)
binding.menuName.text = menu.album.name.resolve(context)
binding.menuInfo.text = menu.album.artists.resolveNames(context)
}

View file

@ -23,9 +23,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.MusicParent
import timber.log.Timber as L
/**
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
}
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
val song = musicRepository.library?.findSong(parcel.songUid) ?: return null
val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null
val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
return Menu.ForSong(parcel.res, song, playWith)
}
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
val album = musicRepository.library?.findAlbum(parcel.albumUid) ?: return null
val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null
return Menu.ForAlbum(parcel.res, album)
}
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
val artist = musicRepository.library?.findArtist(parcel.artistUid) ?: return null
val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null
return Menu.ForArtist(parcel.res, artist)
}
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
val genre = musicRepository.library?.findGenre(parcel.genreUid) ?: return null
val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null
return Menu.ForGenre(parcel.res, genre)
}
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
val playlist = musicRepository.library?.findPlaylist(parcel.playlistUid) ?: return null
val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
return Menu.ForPlaylist(parcel.res, playlist)
}
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
val library = musicRepository.library ?: return null
val songs = parcel.songUids.mapNotNull(library::findSong)
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
return Menu.ForSelection(parcel.res, songs)
}
}

View file

@ -19,37 +19,21 @@
package org.oxycblt.auxio.list.recycler
import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.core.view.isEmpty
import androidx.core.view.isInvisible
import androidx.core.view.updatePaddingRelative
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.MaterialFadingSlider
import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
@ -78,70 +62,33 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Added drag listener
* - Added documentation
* - Completely new design
* - New scroll position backend
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*
* TODO: Add vibration when popup changes
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*/
class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
private val thumbSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val slider = MaterialSlider(context, thumbSize)
private var thumbAnimator: Animator? = null
@SuppressLint("InflateParams")
private val thumbView =
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
thumbSlider.jumpOut(this)
}
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0
private var showingThumb = false
private val hideThumbRunnable = Runnable {
if (!dragging) {
hideThumb()
hideScrollbar()
}
}
private val popupView =
MaterialTextView(context).apply {
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
setTextColor(
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
elevation =
context
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
.toFloat()
background = context.getDrawableCompat(R.drawable.ui_popup)
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
updatePaddingRelative(start = paddingStart, end = paddingEnd)
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
}
}
private val popupSlider =
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
jumpOut(popupView)
}
private var popupAnimator: Animator? = null
private var showingPopup = false
// Touch
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
@ -152,24 +99,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var dragStartY = 0f
private var dragStartThumbOffset = 0
private var fastScrollingPossible = true
var fastScrollingEnabled = true
set(value) {
if (field == value) {
return
}
field = value
if (!value) {
removeCallbacks(hideThumbRunnable)
hideThumb()
hidePopup()
}
listener?.onFastScrollingChanged(field)
}
private var dragging = false
set(value) {
if (field == value) {
@ -187,9 +116,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (field) {
removeCallbacks(hideThumbRunnable)
showScrollbar()
showPopup()
} else {
hidePopup()
postAutoHideScrollbar()
}
@ -201,7 +128,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
init {
overlay.add(thumbView)
overlay.add(popupView)
addItemDecoration(
object : ItemDecoration() {
@ -230,96 +156,26 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() {
updateThumbState()
updateScrollbarState()
thumbView.layoutDirection = layoutDirection
thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft =
if (isRtl) {
thumbPadding.left
} else {
width - thumbPadding.right - thumbWidth
width - thumbPadding.right - thumbSize
}
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
popupView.layoutDirection = layoutDirection
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
layoutManager?.getPosition(child) ?: NO_POSITION
} else {
NO_POSITION
}
val popupText: String
val provider = popupProvider
if (firstAdapterPos != NO_POSITION && provider != null) {
popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?".
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = false
popupText = ""
}
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) {
popupView.text = popupText
val widthMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
thumbPadding.left +
thumbPadding.right +
thumbWidth +
popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
popupLayoutParams.width)
val heightMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
thumbPadding.top +
thumbPadding.bottom +
popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin,
popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec)
if (showingPopup) {
doPopupVibration()
}
}
val popupWidth = popupView.measuredWidth
val popupHeight = popupView.measuredHeight
val popupLeft =
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else {
width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
}
val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.height / 2
val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY)
.coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
.coerceAtMost(
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize)
}
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
updateThumbState()
updateScrollbarState()
// Measure or layout events result in a fake onScrolled call. Ignore those.
if (dx == 0 && dy == 0) {
@ -337,15 +193,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return insets
}
private fun updateThumbState() {
private fun updateScrollbarState() {
// Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
val offsetY = computeVerticalScrollOffset()
if (computeVerticalScrollRange() < height || isEmpty()) {
fastScrollingPossible = false
hideThumb()
hidePopup()
if (computeVerticalScrollRange() < height || childCount == 0) {
return
}
val extentY = computeVerticalScrollExtent()
@ -354,10 +206,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun onItemTouch(event: MotionEvent): Boolean {
if (!fastScrollingEnabled || !fastScrollingPossible) {
dragging = false
return false
}
val eventX = event.x
val eventY = event.y
@ -371,9 +219,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset
} else if (eventX > thumbView.right - thumbWidth / 4) {
dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
} else if (eventX > thumbView.right - thumbSize / 4) {
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset)
} else {
return false
@ -391,8 +238,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
dragStartThumbOffset = thumbOffset
} else {
dragStartY = eventY
dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset)
}
@ -436,65 +282,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun showScrollbar() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingThumb) {
return
}
showingThumb = true
thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
thumbAnimator = slider.slideIn(thumbView).also { it.start() }
}
private fun hideThumb() {
private fun hideScrollbar() {
if (!showingThumb) {
return
}
showingThumb = false
thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
}
private fun showPopup() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingPopup) {
return
}
showingPopup = true
popupAnimator?.cancel()
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
}
private fun hidePopup() {
if (!showingPopup) {
return
}
showingPopup = false
popupAnimator?.cancel()
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
}
private fun doPopupVibration() {
performHapticFeedback(
if (Build.VERSION.SDK_INT >= 27) {
HapticFeedbackConstants.TEXT_HANDLE_MOVE
} else {
HapticFeedbackConstants.KEYBOARD_TAP
})
thumbAnimator = slider.slideOut(thumbView).also { it.start() }
}
// --- LAYOUT STATE ---
private val thumbOffsetRange: Int
get() {
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
return height - thumbPadding.top - thumbPadding.bottom - thumbSize
}
/** An interface to provide text to use in the popup when fast-scrolling. */

View file

@ -92,6 +92,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
// this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
L.d("Lifting ViewHolder")

View file

@ -32,17 +32,16 @@ import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.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.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.

View file

@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
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
/**
* A sorting method.
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> songs.sortBy { it.addedMs }
Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
Direction.ASCENDING -> songs.sortBy { it.dateAdded }
Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
}
}
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name }
when (direction) {
Direction.ASCENDING -> albums.sortBy { it.addedMs }
Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
Direction.ASCENDING -> albums.sortBy { it.dateAdded }
Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
}
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 Auxio Project
* Indexing.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.music
import android.os.Build
/** Version-aware permission identifier for reading audio files. */
val PERMISSION_READ_AUDIO =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_AUDIO
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Exception?) : IndexingState
}
/**
* Represents the current progress of music loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingProgress {
/** Other work is being done that does not have a defined progress. */
data object Indeterminate : IndexingProgress
/**
* Songs are currently being loaded.
*
* @param current The current amount of songs loaded.
* @param total The projected total amount of songs.
*/
data class Songs(val current: Int, val total: Int) : IndexingProgress
}
/**
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoAudioPermissionException : Exception() {
override val message = "Storage permissions are required to load music"
}
/**
* Thrown when no music was found.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoMusicException : Exception() {
override val message = "No music was found on the device"
}

View file

@ -16,25 +16,29 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.musikr
package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import android.os.Parcelable
import androidx.room.TypeConverter
import java.security.MessageDigest
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverCollection
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.ReleaseType
import org.oxycblt.musikr.tag.ReplayGainAdjustment
import org.oxycblt.musikr.util.toUuidOrNull
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
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.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull
/**
* Abstract music data. This contains universal information about all concrete music
@ -42,7 +46,7 @@ import org.oxycblt.musikr.util.toUuidOrNull
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface Music {
sealed interface Music : Item {
/**
* A unique identifier for this music item.
*
@ -77,34 +81,23 @@ sealed interface Music {
class UID
private constructor(
private val format: Format,
private val item: Item,
private val type: MusicType,
private val uuid: UUID
) : Parcelable {
// Cache the hashCode for HashMap efficiency.
@IgnoredOnParcel private var hashCode = format.hashCode()
init {
hashCode = 31 * hashCode + item.hashCode()
hashCode = 31 * hashCode + type.hashCode()
hashCode = 31 * hashCode + uuid.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is UID && format == other.format && item == other.item && uuid == other.uuid
other is UID && format == other.format && type == other.type && uuid == other.uuid
override fun toString() = "${format.namespace}:${item.intCode.toString(16)}-$uuid"
internal enum class Item(val intCode: Int) {
// Item used to be MusicType back when the music module was
// part of Auxio, so these old integer codes remain.
// TODO: Introduce new UID format that removes these.
SONG(0xA10B),
ALBUM(0xA10A),
ARTIST(0xA109),
GENRE(0xA108),
PLAYLIST(0xA107)
}
override fun toString() = "${format.namespace}:${type.intCode.toString(16)}-$uuid"
/**
* Internal marker of [Music.UID] format type.
@ -124,7 +117,7 @@ sealed interface Music {
@TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
/** @see [Music.UID.fromString] */
@TypeConverter fun toMusicUid(string: String?) = string?.let(Companion::fromString)
@TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString)
}
companion object {
@ -132,23 +125,23 @@ sealed interface Music {
* Creates an Auxio-style [UID] of random composition. Used if there is no
* non-subjective, unlikely-to-change metadata of the music.
*
* @param item The type of [Item] that created this [UID].
* @param type The analogous [MusicType] of the item that created this [UID].
*/
internal fun auxio(item: Item): UID {
return UID(Format.AUXIO, item, UUID.randomUUID())
fun auxio(type: MusicType): UID {
return UID(Format.AUXIO, type, UUID.randomUUID())
}
/**
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music.
*
* @param item The type of [Item] that created this [UID].
* @param type The analogous [MusicType] of the item that created this [UID].
* @param updates Block to update the [MessageDigest] hash with the metadata of the
* item. Make sure the metadata hashed semantically aligns with the format
* specification.
* @return A new auxio-style [UID].
*/
internal fun auxio(item: Item, updates: MessageDigest.() -> Unit): UID {
fun auxio(type: MusicType, updates: MessageDigest.() -> Unit): UID {
val digest =
MessageDigest.getInstance("SHA-256").run {
updates()
@ -178,19 +171,19 @@ sealed interface Music {
.or(digest[13].toLong().and(0xFF).shl(16))
.or(digest[14].toLong().and(0xFF).shl(8))
.or(digest[15].toLong().and(0xFF)))
return UID(Format.AUXIO, item, uuid)
return UID(Format.AUXIO, type, uuid)
}
/**
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
* extracted from a file.
*
* @param item The [Item] that created this [UID].
* @param type The analogous [MusicType] of the item that created this [UID].
* @param mbid The analogous MusicBrainz ID for this item that was extracted from a
* file.
* @return A new MusicBrainz-style [UID].
*/
internal fun musicBrainz(item: Item, mbid: UUID) = UID(Format.MUSICBRAINZ, item, mbid)
fun musicBrainz(type: MusicType, mbid: UUID) = UID(Format.MUSICBRAINZ, type, mbid)
/**
* Convert a [UID]'s string representation back into a concrete [UID] instance.
@ -218,8 +211,8 @@ sealed interface Music {
return null
}
val intCode = ids[0].toIntOrNull(16) ?: return null
val type = Item.entries.firstOrNull { it.intCode == intCode } ?: return null
val type =
MusicType.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null
val uuid = ids[1].toUuidOrNull() ?: return null
return UID(format, type, uuid)
}
@ -243,7 +236,6 @@ sealed interface MusicParent : Music {
* @author Alexander Capehart (OxygenCobalt)
*/
interface Song : Music {
override val name: Name.Known
/** The track number. Will be null if no valid track number was present in the metadata. */
val track: Int?
/** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
@ -255,32 +247,23 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe.
*/
val uri: Uri
/** Useful information to quickly obtain the album cover. */
val cover: Cover
/**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file.
*/
val path: Path
/** The [Format] of the audio file. Only intended for display. */
val format: Format
/** The [MimeType] of the audio file. Only intended for display. */
val mimeType: MimeType
/** The size of the audio file, in bytes. */
val size: Long
/** The duration of the audio file, in milliseconds. */
val durationMs: Long
/** The bitrate of the audio file, in kbps. */
val bitrateKbps: Int
/** The sample rate of the audio file, in Hz. */
val sampleRateHz: Int
/** The ReplayGain adjustment to apply during playback. */
val replayGainAdjustment: ReplayGainAdjustment
/**
* The date last modified the audio file was last modified, in milliseconds since the unix
* epoch.
*/
val modifiedMs: Long
/** The time the audio file was added to the device, in milliseconds since the unix epoch. */
val addedMs: Long
/** Useful information to quickly obtain the album cover. */
val cover: Cover?
/** The date the audio file was added to the device, as a unix epoch timestamp. */
val dateAdded: Long
/**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead.
@ -313,12 +296,12 @@ interface Album : MusicParent {
* [ReleaseType.Album].
*/
val releaseType: ReleaseType
/** Cover information from album's songs. */
val covers: CoverCollection
/** Cover information from the template song used for the album. */
val cover: ParentCover
/** The duration of all songs in the album, in milliseconds. */
val durationMs: Long
/** The earliest date a song in this album was added, in milliseconds since the unix epoch. */
val addedMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
val dateAdded: Long
/**
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than
* one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
@ -344,7 +327,7 @@ interface Artist : MusicParent {
*/
val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val covers: CoverCollection
val cover: ParentCover
/** The [Genre]s of this artist. */
val genres: List<Genre>
}
@ -360,7 +343,7 @@ interface Genre : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val covers: CoverCollection
val cover: ParentCover
}
/**
@ -374,5 +357,34 @@ interface Playlist : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val covers: CoverCollection
val cover: ParentCover?
}
/**
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
* localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.name.resolve(context) }
/**
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.name != b.name) {
return false
}
}
return true
}

View file

@ -19,30 +19,31 @@
package org.oxycblt.auxio.music
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import java.util.LinkedList
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Musikr
import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
import org.oxycblt.auxio.util.forEachWithTimeout
import timber.log.Timber as L
/**
@ -57,9 +58,10 @@ import timber.log.Timber as L
* configurations
*/
interface MusicRepository {
/** The current library */
val library: Library?
/** The current music information found on the device. */
val deviceLibrary: DeviceLibrary?
/** The current user-defined music information. */
val userLibrary: UserLibrary?
/** The current state of music loading. Null if no load has occurred yet. */
val indexingState: IndexingState?
@ -173,7 +175,7 @@ interface MusicRepository {
* @param withCache Whether to load with the music cache or not.
* @return The top-level music loading [Job] started.
*/
suspend fun index(worker: IndexingWorker, withCache: Boolean)
fun index(worker: IndexingWorker, withCache: Boolean): Job
/** A listener for changes to the stored music information. */
interface UpdateListener {
@ -188,8 +190,8 @@ interface MusicRepository {
/**
* Flags indicating which kinds of music information changed.
*
* @param deviceLibrary Whether the current songs/albums/artists/genres has changed.
* @param userLibrary Whether the current playlists have changed.
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
* @param userLibrary Whether the current [Playlist]s have changed.
*/
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
@ -201,6 +203,12 @@ interface MusicRepository {
/** A persistent worker that can load music in the background. */
interface IndexingWorker {
/** A [Context] required to read device storage */
val workerContext: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
/**
* Request that the music loading process ([index]) should be started. Any prior loads
* should be canceled.
@ -211,42 +219,22 @@ interface MusicRepository {
}
}
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Exception?) : IndexingState
}
class MusicRepositoryImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val cache: MutableCache,
private val storedPlaylists: StoredPlaylists,
private val settingCovers: SettingCovers,
private val cacheRepository: CacheRepository,
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor,
private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory,
private val musicSettings: MusicSettings
) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@Volatile private var indexingWorker: IndexingWorker? = null
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
@Volatile override var library: MutableLibrary? = null
@Volatile override var deviceLibrary: DeviceLibrary? = null
@Volatile override var userLibrary: MutableUserLibrary? = null
@Volatile private var previousCompletedState: IndexingState.Completed? = null
@Volatile private var currentIndexingState: IndexingState? = null
override val indexingState: IndexingState?
@ -283,7 +271,7 @@ constructor(
}
@Synchronized
override fun registerWorker(worker: IndexingWorker) {
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
if (indexingWorker != null) {
L.w("Worker is already registered")
return
@ -293,7 +281,7 @@ constructor(
}
@Synchronized
override fun unregisterWorker(worker: IndexingWorker) {
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
if (indexingWorker !== worker) {
L.w("Given worker did not match current worker")
return
@ -305,51 +293,41 @@ constructor(
@Synchronized
override fun find(uid: Music.UID) =
(library?.run {
findSong(uid)
?: findAlbum(uid)
?: findArtist(uid)
?: findGenre(uid)
?: findPlaylist(uid)
})
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
?: userLibrary?.findPlaylist(uid))
override suspend fun createPlaylist(name: String, songs: List<Song>) {
val library = synchronized(this) { library ?: return }
val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Creating playlist $name with ${songs.size} songs")
val newLibrary = library.createPlaylist(name, songs)
synchronized(this) { this.library = newLibrary }
userLibrary.createPlaylist(name, songs)
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
}
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val library = synchronized(this) { library ?: return }
val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Renaming $playlist to $name")
val newLibrary = library.renamePlaylist(playlist, name)
synchronized(this) { this.library = newLibrary }
userLibrary.renamePlaylist(playlist, name)
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
}
override suspend fun deletePlaylist(playlist: Playlist) {
val library = synchronized(this) { library ?: return }
val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Deleting $playlist")
val newLibrary = library.deletePlaylist(playlist)
synchronized(this) { this.library = newLibrary }
userLibrary.deletePlaylist(playlist)
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
}
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val library = synchronized(this) { library ?: return }
val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Adding ${songs.size} songs to $playlist")
val newLibrary = library.addToPlaylist(playlist, songs)
synchronized(this) { this.library = newLibrary }
userLibrary.addToPlaylist(playlist, songs)
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
}
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val library = synchronized(this) { library ?: return }
val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Rewriting $playlist with ${songs.size} songs")
val newLibrary = library.rewritePlaylist(playlist, songs)
synchronized(this) { this.library = newLibrary }
userLibrary.rewritePlaylist(playlist, songs)
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
}
@ -359,53 +337,241 @@ constructor(
indexingWorker?.requestIndex(withCache)
}
override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
L.d("Begin index [cache=$withCache]")
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try {
indexImpl(withCache)
indexImpl(context, scope, withCache)
} catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine.
L.d("Loading routine was cancelled")
throw e
} catch (e: Exception) {
// Music loading process failed due to something we have not handled.
// TODO: Still want to display this error eventually
L.e("Music indexing failed")
L.e(e.stackTraceToString())
emitIndexingCompletion(e)
}
}
private suspend fun indexImpl(withCache: Boolean) {
private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
// TODO: Find a way to break up this monster of a method, preferably as another class.
val start = System.currentTimeMillis()
// Make sure we have permissions before going forward. Theoretically this would be better
// done at the UI level, but that intertwines logic and display too much.
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
L.e("Permissions were not granted")
throw NoAudioPermissionException()
}
// Obtain configuration information
val constraints =
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
val separators = Separators.from(musicSettings.separators)
val nameFactory =
if (musicSettings.intelligentSorting) {
Naming.intelligent()
Name.Known.IntelligentFactory
} else {
Naming.simple()
Name.Known.SimpleFactory
}
val locations = musicSettings.musicLocations
val withHidden = musicSettings.withHidden
val currentRevision = musicSettings.revision
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
val cache = if (withCache) cache else WriteOnlyMutableCache(cache)
val covers = settingCovers.mutate(context, newRevision)
val storage = Storage(cache, covers, storedPlaylists)
val interpretation = Interpretation(nameFactory, separators, withHidden)
val result =
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
// Music loading completed, update the revision right now so we re-use this work
// later.
musicSettings.revision = newRevision
// Deliver the library to the rest of the app
// This will more or less block until all required item translation and
// cleanup finishes.
emitLibrary(result.library)
// Clean up old data that is now impossible for the app to be using.
result.cleanup()
// Finish up loading.
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
// to figure out what songs are (probably) on the device, and the latter will be needed
// for discovery (described later). These have no shared state, so they are done in
// parallel.
L.d("Starting MediaStore query")
emitIndexingProgress(IndexingProgress.Indeterminate)
val mediaStoreQueryJob =
scope.async {
val query =
try {
mediaStoreExtractor.query(constraints)
} catch (e: Exception) {
// Normally, errors in an async call immediately bubble up to the Looper
// and crash the app. Thus, we have to wrap any error into a Result
// and then manually forward it to the try block that indexImpl is
// called from.
return@async Result.failure(e)
}
Result.success(query)
}
// Since this main thread is a co-routine, we can do operations in parallel in a way
// identical to calling async.
val cache =
if (withCache) {
L.d("Reading cache")
cacheRepository.readCache()
} else {
null
}
L.d("Awaiting MediaStore query")
val query = mediaStoreQueryJob.await().getOrThrow()
// We now have all the information required to start the "discovery" process. This
// is the point at which Auxio starts scanning each file given from MediaStore and
// transforming it into a music library. MediaStore normally
L.d("Starting discovery")
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
// MediaStoreExtractor discovers all music on the device, and forwards them to either
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
L.d("Starting MediaStore discovery")
val mediaStoreJob =
scope.async {
try {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
} catch (e: Exception) {
// To prevent a deadlock, we want to close the channel with an exception
// to cascade to and cancel all other routines before finally bubbling up
// to the main extractor loop.
L.e("MediaStore extraction failed: $e")
incompleteSongs.close(
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
return@async
}
incompleteSongs.close()
}
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
// metadata for them, and then forwards it to DeviceLibrary.
L.d("Starting tag extraction")
val tagJob =
scope.async {
try {
tagExtractor.consume(incompleteSongs, completeSongs)
} catch (e: Exception) {
L.e("Tag extraction failed: $e")
completeSongs.close(
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
return@async
}
completeSongs.close()
}
// DeviceLibrary constructs music parent instances as song information is provided,
// and then forwards them to the primary loading loop.
L.d("Starting DeviceLibrary creation")
val deviceLibraryJob =
scope.async(Dispatchers.Default) {
val deviceLibrary =
try {
deviceLibraryFactory.create(
completeSongs, processedSongs, separators, nameFactory)
} catch (e: Exception) {
L.e("DeviceLibrary creation failed: $e")
processedSongs.close(
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
return@async Result.failure(e)
}
processedSongs.close()
Result.success(deviceLibrary)
}
// We could keep track of a total here, but we also need to collate this RawSong information
// for when we write the cache later on in the finalization step.
val rawSongs = LinkedList<RawSong>()
// Use a longer timeout so that dependent components can timeout and throw errors that
// provide more context than if we timed out here.
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
rawSongs.add(it)
// Since discovery takes up the bulk of the music loading process, we switch to
// indicating a defined amount of loaded songs in comparison to the projected amount
// of songs that were queried.
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
}
withTimeout(DEFAULT_TIMEOUT) {
mediaStoreJob.await()
tagJob.await()
}
// Deliberately done after the involved initialization step to make it less likely
// that the short-circuit occurs so quickly as to break the UI.
// TODO: Do not error, instead just wipe the entire library.
if (rawSongs.isEmpty()) {
L.e("Music library was empty")
throw NoMusicException()
}
// Now that the library is effectively loaded, we can start the finalization step, which
// involves writing new cache information and creating more music data that is derived
// from the library (e.g playlists)
L.d("Discovered ${rawSongs.size} songs, starting finalization")
// We have no idea how long the cache will take, and the playlist construction
// will be too fast to indicate, so switch back to an indeterminate state.
emitIndexingProgress(IndexingProgress.Indeterminate)
// The UserLibrary job is split into a query and construction step, a la MediaStore.
// This way, we can start working on playlists even as DeviceLibrary might still be
// working on parent information.
L.d("Starting UserLibrary query")
val userLibraryQueryJob =
scope.async {
val rawPlaylists =
try {
userLibraryFactory.query()
} catch (e: Exception) {
return@async Result.failure(e)
}
Result.success(rawPlaylists)
}
// The cache might not exist, or we might have encountered a song not present in it.
// Both situations require us to rewrite the cache in bulk. This is also done parallel
// since the playlist read will probably take some time.
// TODO: Read/write from the cache incrementally instead of in bulk?
if (cache == null || cache.invalidated) {
L.d("Writing cache [why=${cache?.invalidated}]")
cacheRepository.writeCache(rawSongs)
}
// Create UserLibrary once we finally get the required components for it.
L.d("Awaiting UserLibrary query")
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
L.d("Awaiting DeviceLibrary creation")
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
L.d("Starting UserLibrary creation")
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
// Loading process is functionally done, indicate such
L.d(
"Successfully indexed music library [device=$deviceLibrary " +
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
emitIndexingCompletion(null)
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean
// We want to make sure that all reads and writes are synchronized due to the sheer
// amount of consumers of MusicRepository.
// TODO: Would Atomics not be a better fit here?
synchronized(this) {
// It's possible that this reload might have changed nothing, so make sure that
// hasn't happened before dispatching a change to all consumers.
deviceLibraryChanged = this.deviceLibrary != deviceLibrary
userLibraryChanged = this.userLibrary != userLibrary
if (!deviceLibraryChanged && !userLibraryChanged) {
L.d("Library has not changed, skipping update")
return
}
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
// so switch to it.
withContext(Dispatchers.Main) {
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
}
private suspend fun emitIndexingProgress(progress: IndexingProgress) {
@ -418,39 +584,6 @@ constructor(
}
}
private suspend fun emitLibrary(newLibrary: MutableLibrary) {
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean
// We want to make sure that all reads and writes are synchronized due to the sheer
// amount of consumers of MusicRepository.
synchronized(this) {
// It's possible that this reload might have changed nothing, so make sure that
// hasn't happened before dispatching a change to all consumers.
// This is an old compat shim back when device library and user library were different
// thinks. For the sake of avoiding drastic changes, it sticks around.
// TODO: Remove this once you start work on kindred.
deviceLibraryChanged =
this.library?.songs != newLibrary.songs ||
this.library?.albums != newLibrary.albums ||
this.library?.artists != newLibrary.artists ||
this.library?.genres != newLibrary.genres
userLibraryChanged = this.library?.playlists != newLibrary.playlists
if (!deviceLibraryChanged && !userLibraryChanged) {
L.d("Library has not changed, skipping update")
return
}
this.library = newLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
// so switch to it.
withContext(Dispatchers.Main) {
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
}
private suspend fun emitIndexingCompletion(error: Exception?) {
yield()
synchronized(this) {

View file

@ -21,11 +21,11 @@ package org.oxycblt.auxio.music
import android.content.Context
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.musikr.fs.MusicLocation
import timber.log.Timber as L
/**
@ -34,14 +34,10 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt)
*/
interface MusicSettings : Settings<MusicSettings.Listener> {
/** The current library revision. */
var revision: UUID?
/** The locations of music to load. */
var musicLocations: List<MusicLocation>
/** The configuration on how to handle particular directories in the music library. */
var musicDirs: MusicDirectories
/** Whether to exclude non-music audio files from the music library. */
val excludeNonMusic: Boolean
/** Whether to ignore hidden files and directories during music loading. */
val withHidden: Boolean
/** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */
@ -50,8 +46,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val intelligentSorting: Boolean
interface Listener {
/** Called when the current music locations changed. */
fun onMusicLocationsChanged() {}
/** Called when a setting controlling how music is loaded has changed. */
fun onIndexingSettingChanged() {}
/** Called when the [shouldBeObserving] configuration has changed. */
@ -59,45 +53,35 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
}
}
class MusicSettingsImpl @Inject constructor(@ApplicationContext private val context: Context) :
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
override var revision: UUID?
get() =
sharedPreferences
.getString(getString(R.string.set_key_library_revision), null)
?.let(UUID::fromString)
set(value) {
sharedPreferences.edit {
putString(getString(R.string.set_key_library_revision), value.toString())
apply()
}
}
override var musicLocations: List<MusicLocation>
class MusicSettingsImpl
@Inject
constructor(
@ApplicationContext context: Context,
private val documentPathFactory: DocumentPathFactory
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
override var musicDirs: MusicDirectories
get() {
val locations =
sharedPreferences.getString(getString(R.string.set_key_music_locations), null)
?: return emptyList()
return MusicLocation.existing(context, locations)
val dirs =
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
?: emptySet())
.mapNotNull(documentPathFactory::fromDocumentId)
return MusicDirectories(
dirs,
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
}
set(value) {
sharedPreferences.edit {
putString(
getString(R.string.set_key_music_locations), MusicLocation.toString(value))
commit()
// Sometimes changing this setting just won't actually trigger the listener.
// Only this one. No idea why.
listener?.onMusicLocationsChanged()
putStringSet(
getString(R.string.set_key_music_dirs),
value.dirs.map(documentPathFactory::toDocumentId).toSet())
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
apply()
}
}
override val excludeNonMusic: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
override val withHidden: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), false)
override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
@ -119,14 +103,11 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data)
when (key) {
getString(R.string.set_key_music_locations) -> {
L.d("Dispatching music locations change")
listener.onMusicLocationsChanged()
}
getString(R.string.set_key_exclude_non_music),
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names),
getString(R.string.set_key_with_hidden),
getString(R.string.set_key_exclude_non_music) -> {
getString(R.string.set_key_auto_sort_names) -> {
L.d("Dispatching indexing setting change for $key")
listener.onIndexingSettingChanged()
}

View file

@ -27,10 +27,15 @@ import org.oxycblt.auxio.R
* @author Alexander Capehart (OxygenCobalt)
*/
enum class MusicType {
/** @see Song */
SONGS,
/** @see Album */
ALBUMS,
/** @see Artist */
ARTISTS,
/** @see Genre */
GENRES,
/** @see Playlist */
PLAYLISTS;
/**

View file

@ -1,173 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* MusicUtil.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.music
import android.content.Context
import java.text.ParseException
import java.text.SimpleDateFormat
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.Placeholder
import org.oxycblt.musikr.tag.ReleaseType
import org.oxycblt.musikr.tag.ReleaseType.Refinement
import timber.log.Timber
fun Name.resolve(context: Context) =
when (this) {
is Name.Known -> raw
is Name.Unknown ->
when (placeholder) {
Placeholder.ALBUM -> context.getString(R.string.def_album)
Placeholder.ARTIST -> context.getString(R.string.def_artist)
Placeholder.GENRE -> context.getString(R.string.def_genre)
}
}
/**
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
* localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.name.resolve(context) }
/**
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.name != b.name) {
return false
}
}
return true
}
/**
* Resolve this instance into a human-readable date.
*
* @param context [Context] required to get human-readable names.
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan 2020")
* will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will be
* properly localized.
*/
fun Date.resolve(context: Context) =
// Unable to create fine-grained date, just format as a year.
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
private fun Date.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) {
Timber.e("Unable to parse fine-grained date: $e")
return null
}
// Reformat as a readable month and year
format.applyPattern("MMM yyyy")
return format.format(date)
}
fun Disc?.resolve(context: Context) =
this?.run { context.getString(R.string.fmt_disc_no, number) }
?: context.getString(R.string.def_disc)
/**
* Resolve this instance into a human-readable date range.
*
* @param context [Context] required to get human-readable names.
* @return If the date has a maximum value, then a `min - max` formatted string will be returned
* with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the
* formatted name of the minimum [Date] will be returned.
*/
fun Date.Range.resolve(context: Context) =
if (min != max) {
context.getString(R.string.fmt_date_range, min.resolve(context), max.resolve(context))
} else {
min.resolve(context)
}
fun ReleaseType.resolve(context: Context) =
when (this) {
is ReleaseType.Album ->
when (refinement) {
null -> context.getString(R.string.lbl_album)
Refinement.LIVE -> context.getString(R.string.lbl_album_live)
Refinement.REMIX -> context.getString(R.string.lbl_album_remix)
}
is ReleaseType.EP ->
when (refinement) {
null -> context.getString(R.string.lbl_ep)
Refinement.LIVE -> context.getString(R.string.lbl_ep_live)
Refinement.REMIX -> context.getString(R.string.lbl_ep_remix)
}
is ReleaseType.Single ->
when (refinement) {
null -> context.getString(R.string.lbl_single)
Refinement.LIVE -> context.getString(R.string.lbl_single_live)
Refinement.REMIX -> context.getString(R.string.lbl_single_remix)
}
is ReleaseType.Compilation ->
when (refinement) {
null -> context.getString(R.string.lbl_compilation)
Refinement.LIVE -> context.getString(R.string.lbl_compilation_live)
Refinement.REMIX -> context.getString(R.string.lbl_compilation_remix)
}
is ReleaseType.Soundtrack -> context.getString(R.string.lbl_soundtrack)
is ReleaseType.Mix -> context.getString(R.string.lbl_mix)
is ReleaseType.Mixtape -> context.getString(R.string.lbl_mixtape)
is ReleaseType.Demo -> context.getString(R.string.lbl_demo)
}
fun Format.resolve(context: Context): String =
when (this) {
is Format.MPEG3 -> context.getString(R.string.cdc_mp3)
is Format.MPEG4 ->
containing?.let { context.getString(R.string.cnt_mp4, it.resolve(context)) }
?: context.getString(R.string.cdc_mp4)
is Format.AAC -> context.getString(R.string.cdc_aac)
is Format.ALAC -> context.getString(R.string.cdc_alac)
is Format.Ogg ->
containing?.let { context.getString(R.string.cnt_ogg, it.resolve(context)) }
?: context.getString(R.string.cdc_ogg)
is Format.Opus -> context.getString(R.string.cdc_opus)
is Format.Vorbis -> context.getString(R.string.cdc_vorbis)
is Format.FLAC -> context.getString(R.string.cdc_flac)
is Format.Wav -> context.getString(R.string.cdc_wav)
is Format.Unknown -> extension ?: context.getString(R.string.cdc_unknown)
}

View file

@ -18,12 +18,10 @@
package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -31,15 +29,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.ExportConfig
import org.oxycblt.musikr.playlist.ExternalPlaylistManager
import timber.log.Timber as L
/**
@ -51,11 +44,10 @@ import timber.log.Timber as L
class MusicViewModel
@Inject
constructor(
@ApplicationContext context: Context,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository
private val musicRepository: MusicRepository,
private val externalPlaylistManager: ExternalPlaylistManager
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val externalPlaylistManager = ExternalPlaylistManager.from(context)
private val _indexingState = MutableStateFlow<IndexingState?>(null)
@ -93,14 +85,14 @@ constructor(
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val library = musicRepository.library ?: return
val deviceLibrary = musicRepository.deviceLibrary ?: return
_statistics.value =
Statistics(
library.songs.size,
library.albums.size,
library.artists.size,
library.genres.size,
library.songs.sumOf { it.durationMs })
deviceLibrary.songs.size,
deviceLibrary.albums.size,
deviceLibrary.artists.size,
deviceLibrary.genres.size,
deviceLibrary.songs.sumOf { it.durationMs })
L.d("Updated statistics: ${_statistics.value}")
}
@ -170,10 +162,10 @@ constructor(
return@launch
}
val library = musicRepository.library ?: return@launch
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs =
importedPlaylist.paths.mapNotNull {
it.firstNotNullOfOrNull(library::findSongByPath)
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
}
if (songs.isEmpty()) {

View file

@ -0,0 +1,187 @@
/*
* Copyright (c) 2023 Auxio Project
* CacheDatabase.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.music.cache
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao
}
@Dao
interface CachedSongsDao {
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>)
}
@Entity
@TypeConverters(CachedSong.Converters::class)
data class CachedSong(
/**
* The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
*/
@PrimaryKey var mediaStoreId: Long,
/** @see RawSong.dateAdded */
var dateAdded: Long,
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long,
/** @see RawSong.size */
var size: Long? = null,
/** @see RawSong */
var durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float? = null,
/** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RawSong.name */
var name: String,
/** @see RawSong.sortName */
var sortName: String? = null,
/** @see RawSong.track */
var track: Int? = null,
/** @see RawSong.name */
var disc: Int? = null,
/** @See RawSong.subtitle */
var subtitle: String? = null,
/** @see RawSong.date */
var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
var albumName: String,
/** @see RawSong.albumSortName */
var albumSortName: String? = null,
/** @see RawSong.releaseTypes */
var releaseTypes: List<String> = listOf(),
/** @see RawSong.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.artistNames */
var artistNames: List<String> = listOf(),
/** @see RawSong.artistSortNames */
var artistSortNames: List<String> = listOf(),
/** @see RawSong.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.albumArtistNames */
var albumArtistNames: List<String> = listOf(),
/** @see RawSong.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawSong.genreNames */
var genreNames: List<String> = listOf()
) {
fun copyToRaw(rawSong: RawSong) {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
rawSong.size = size
rawSong.durationMs = durationMs
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
rawSong.track = track
rawSong.disc = disc
rawSong.subtitle = subtitle
rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
rawSong.releaseTypes = releaseTypes
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
rawSong.artistNames = artistNames
rawSong.artistSortNames = artistSortNames
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
rawSong.albumArtistNames = albumArtistNames
rawSong.albumArtistSortNames = albumArtistSortNames
rawSong.genreNames = genreNames
}
object Converters {
@TypeConverter
fun fromMultiValue(values: List<String>) =
values.joinToString(";") { it.replace(";", "\\;") }
@TypeConverter
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
}
companion object {
fun fromRaw(rawSong: RawSong) =
CachedSong(
mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
dateModified =
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
musicBrainzId = rawSong.musicBrainzId,
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
sortName = rawSong.sortName,
size = rawSong.size,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
track = rawSong.track,
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,
releaseTypes = rawSong.releaseTypes,
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
artistNames = rawSong.artistNames,
artistSortNames = rawSong.artistSortNames,
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
albumArtistNames = rawSong.albumArtistNames,
albumArtistSortNames = rawSong.albumArtistSortNames,
genreNames = rawSong.genreNames)
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2025 Auxio Project
* MusikrShimModule.kt is part of Auxio.
* Copyright (c) 2023 Auxio Project
* CacheModule.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
@ -16,31 +16,34 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.shim
package org.oxycblt.auxio.music.cache
import android.content.Context
import androidx.room.Room
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.cache.db.MutableDBCache
import org.oxycblt.musikr.playlist.db.StoredPlaylists
@Module
@InstallIn(SingletonComponent::class)
class MusikrShimModule {
@Singleton
@Provides
fun cache(@ApplicationContext context: Context): MutableCache = MutableDBCache.from(context)
@Singleton
@Provides
fun storedPlaylists(@ApplicationContext context: Context) = StoredPlaylists.from(context)
@Provides
fun updateTrackerFactory(@ApplicationContext context: Context): UpdateTrackerFactory =
UpdateTrackerFactoryImpl(context)
interface CacheModule {
@Binds fun cacheRepository(cacheRepository: CacheRepositoryImpl): CacheRepository
}
@Module
@InstallIn(SingletonComponent::class)
class CacheRoomModule {
@Singleton
@Provides
fun database(@ApplicationContext context: Context) =
Room.databaseBuilder(
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
.fallbackToDestructiveMigration()
.build()
@Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao()
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2022 Auxio Project
* CacheRepository.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.music.cache
import javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong
import timber.log.Timber as L
/**
* A repository allowing access to cached metadata obtained in prior music loading operations.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface CacheRepository {
/**
* Read the current [Cache], if it exists.
*
* @return The stored [Cache], or null if it could not be obtained.
*/
suspend fun readCache(): Cache?
/**
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
*
* @param rawSongs The [rawSongs] to write to the cache.
*/
suspend fun writeCache(rawSongs: List<RawSong>)
}
class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) :
CacheRepository {
override suspend fun readCache(): Cache? =
try {
// Faster to load the whole database into memory than do a query on each
// populate call.
val songs = cachedSongsDao.readSongs()
L.d("Successfully read ${songs.size} songs from cache")
CacheImpl(songs)
} catch (e: Exception) {
L.e("Unable to load cache database.")
L.e(e.stackTraceToString())
null
}
override suspend fun writeCache(rawSongs: List<RawSong>) {
try {
// Still write out whatever data was extracted.
cachedSongsDao.nukeSongs()
L.d("Successfully deleted old cache")
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
L.d("Successfully wrote ${rawSongs.size} songs to cache")
} catch (e: Exception) {
L.e("Unable to save cache database.")
L.e(e.stackTraceToString())
}
}
}
/**
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
* [CacheRepository].
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Cache {
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
val invalidated: Boolean
/**
* Populate a [RawSong] from a cache entry, if it exists.
*
* @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
fun populate(rawSong: RawSong): Boolean
}
private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
private val cacheMap = buildMap {
for (cachedSong in cachedSongs) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
override var invalidated = false
override fun populate(rawSong: RawSong): Boolean {
// For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
// check for it anyway.
val cachedSong = cacheMap[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
return true
}
// We could not populate this song. This means our cache is stale and should be
// re-written with newly-loaded music.
invalidated = true
return false
}
}

View file

@ -33,10 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**

View file

@ -29,11 +29,10 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L
/**
@ -53,9 +52,6 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
builder
.setTitle(R.string.lbl_confirm_delete_playlist)
.setPositiveButton(R.string.lbl_delete) { _, _ ->
// Normally the navigateUp will occur after this, which then collides with the
// playlist view's navigation. Forcefully navigate up to stop this.
findNavController().navigateUp()
// Now we can delete the playlist for-real this time.
musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)

View file

@ -31,13 +31,12 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.ExportConfig
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**

View file

@ -25,7 +25,6 @@ import org.oxycblt.auxio.list.ClickableListListener
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.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater

View file

@ -25,14 +25,14 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.resolve
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.ExportConfig
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.ExportConfig
import timber.log.Timber as L
/**
@ -89,13 +89,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
override fun onMusicChanges(changes: MusicRepository.Changes) {
var refreshChoicesWith: List<Song>? = null
val library = musicRepository.library
if (changes.deviceLibrary && library != null) {
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
_currentPendingNewPlaylist.value =
_currentPendingNewPlaylist.value?.let { pendingPlaylist ->
PendingNewPlaylist(
pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { library.findSong(it.uid) },
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
pendingPlaylist.template,
pendingPlaylist.reason)
}
@ -104,7 +104,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
_currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs
.mapNotNull { library.findSong(it.uid) }
.mapNotNull { deviceLibrary.findSong(it.uid) }
.ifEmpty { null }
.also { refreshChoicesWith = it }
}
@ -127,7 +127,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
_currentPlaylistToExport.value =
_currentPlaylistToExport.value?.let { playlist ->
musicRepository.library?.findPlaylist(playlist.uid)
musicRepository.userLibrary?.findPlaylist(playlist.uid)
}
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
}
@ -153,14 +153,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
reason: PlaylistDecision.New.Reason
) {
L.d("Opening ${songUids.size} songs to create a playlist from")
val library = musicRepository.library ?: return
val userLibrary = musicRepository.userLibrary ?: return
val songs =
musicRepository.library
musicRepository.deviceLibrary
?.let { songUids.mapNotNull(it::findSong) }
?.also(::refreshPlaylistChoices)
val possibleName =
musicRepository.library?.let {
musicRepository.userLibrary?.let {
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
var i = 1
var possibleName: String
@ -168,7 +168,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
possibleName = context.getString(R.string.fmt_def_playlist, i)
L.d("Trying $possibleName as a playlist name")
++i
} while (library.playlists.any { it.name.resolve(context) == possibleName })
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
L.d("$possibleName is unique, using it as the playlist name")
possibleName
}
@ -194,8 +194,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
reason: PlaylistDecision.Rename.Reason
) {
L.d("Opening playlist $playlistUid to rename")
val playlist = musicRepository.library?.findPlaylist(playlistUid)
val applySongs = musicRepository.library?.let { applySongUids.mapNotNull(it::findSong) }
val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
val applySongs =
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
_currentPendingRenamePlaylist.value =
if (playlist != null && applySongs != null) {
@ -215,7 +216,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
L.d("Opening playlist $playlistUid to export")
// TODO: Add this guard to the rest of the methods here
if (_currentPlaylistToExport.value?.uid == playlistUid) return
_currentPlaylistToExport.value = musicRepository.library?.findPlaylist(playlistUid)
_currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToExport.value == null) {
L.w("Given playlist UID to export was invalid")
} else {
@ -240,7 +241,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
*/
fun setPlaylistToDelete(playlistUid: Music.UID) {
L.d("Opening playlist $playlistUid to delete")
_currentPlaylistToDelete.value = musicRepository.library?.findPlaylist(playlistUid)
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) {
L.w("Given playlist UID to delete was invalid")
}
@ -265,8 +266,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
}
else -> {
val trimmed = name.trim()
val library = musicRepository.library
if (library != null && library.findPlaylistByName(trimmed) == null) {
val userLibrary = musicRepository.userLibrary
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
L.d("Chosen name is valid")
ChosenName.Valid(trimmed)
} else {
@ -285,7 +286,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
fun setSongsToAdd(songUids: Array<Music.UID>) {
L.d("Opening ${songUids.size} songs to add to a playlist")
_currentSongsToAdd.value =
musicRepository.library
musicRepository.deviceLibrary
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
?.also(::refreshPlaylistChoices)
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
@ -294,10 +295,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
}
private fun refreshPlaylistChoices(songs: List<Song>) {
val library = musicRepository.library ?: return
val userLibrary = musicRepository.userLibrary ?: return
L.d("Refreshing playlist choices")
_playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(library.playlists).map {
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
val songSet = it.songs.toSet()
PlaylistChoice(it, songs.all(songSet::contains))
}
@ -354,4 +355,4 @@ sealed interface ChosenName {
* [Playlist].
* @author Alexander Capehart (OxygenCobalt)
*/
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean)
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item

View file

@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -0,0 +1,42 @@
package org.oxycblt.auxio.music.device
interface AlbumTree {
fun register(linkedSong: ArtistTree.LinkedSong): LinkedSong
fun resolve(): Collection<AlbumImpl>
data class LinkedSong(
val linkedArtistSong: ArtistTree.LinkedSong,
val album: Linked<AlbumImpl, SongImpl>
)
}
interface ArtistTree {
fun register(preSong: GenreTree.LinkedSong): LinkedSong
fun resolve(): Collection<ArtistImpl>
data class LinkedSong(
val linkedGenreSong: GenreTree.LinkedSong,
val linkedAlbum: LinkedAlbum,
val artists: Linked<List<ArtistImpl>, SongImpl>
)
data class LinkedAlbum(
val preAlbum: PreAlbum,
val artists: Linked<List<ArtistImpl>, AlbumImpl>
)
}
interface GenreTree {
fun register(preSong: PreSong): LinkedSong
fun resolve(): Collection<GenreImpl>
data class LinkedSong(
val preSong: PreSong,
val genres: Linked<List<GenreImpl>, SongImpl>
)
}
interface Linked<P, C> {
fun resolve(child: C): P
}

View file

@ -0,0 +1,399 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceLibrary.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.music.device
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
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.Song
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.sendWithTimeout
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/**
* Organized music library information obtained from device storage.
*
* This class allows for the creation of a well-formed music library graph from raw song
* information. Instances are immutable. It's generally not expected to create this yourself and
* instead use [MusicRepository].
*
* @author Alexander Capehart
*/
interface DeviceLibrary {
/** All [Song]s in this [DeviceLibrary]. */
val songs: Collection<Song>
/** All [Album]s in this [DeviceLibrary]. */
val albums: Collection<Album>
/** All [Artist]s in this [DeviceLibrary]. */
val artists: Collection<Artist>
/** All [Genre]s in this [DeviceLibrary]. */
val genres: Collection<Genre>
/**
* Find a [Song] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findSong(uid: Music.UID): Song?
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
*
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri): Song?
/**
* Find a [Song] instance corresponding to the given [Path].
*
* @param path [Path] to search for.
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
*/
fun findSongByPath(path: Path): Song?
/**
* Find a [Album] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Album], or null if one was not found.
*/
fun findAlbum(uid: Music.UID): Album?
/**
* Find a [Artist] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Artist], or null if one was not found.
*/
fun findArtist(uid: Music.UID): Artist?
/**
* Find a [Genre] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Genre], or null if one was not found.
*/
fun findGenre(uid: Music.UID): Genre?
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
interface Factory {
/**
* Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of
* [RawSong] instances.
*
* @param rawSongs A stream of [RawSong] instances to process.
* @param processedSongs A stream of [RawSong] instances that will have been processed by
* the instance.
*/
suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibrary
}
}
class DeviceLibraryFactoryImpl2 @Inject constructor(
val interpreterFactory: Interpreter.Factory
) : DeviceLibrary.Factory {
override suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibrary {
val interpreter = interpreterFactory.create(nameFactory, separators)
rawSongs.forEachWithTimeout { rawSong ->
interpreter.consume(rawSong)
processedSongs.sendWithTimeout(rawSong)
}
return interpreter.resolve()
}
}
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
override suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibrary {
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
// All music information is grouped as it is indexed by other components.
rawSongs.forEachWithTimeout { rawSong ->
val song = SongImpl(rawSong, nameFactory, separators)
// At times the indexer produces duplicate songs, try to filter these. Comparing by
// UID is sufficient for something like this, and also prevents collisions from
// causing severe issues elsewhere.
if (songGrouping.containsKey(song.uid)) {
L.w(
"Duplicate song found: ${song.path} " +
"collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}")
// We still want to say that we "processed" the song so that the user doesn't
// get confused at why the bar was only partly filled by the end of the loading
// process.
processedSongs.sendWithTimeout(rawSong)
return@forEachWithTimeout
}
songGrouping[song.uid] = song
// Group the new song into an album.
appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
compareSongTracks(old, new)
}
// Group the song into each of it's artists.
for (rawArtist in song.rawArtists) {
appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
// Artist information from earlier dates is prioritized, as it is less likely to
// change with the addition of new tracks. Fall back to the name otherwise.
check(old is SongImpl) // This should always be the case.
compareSongDates(old, new)
}
}
// Group the song into each of it's genres.
for (rawGenre in song.rawGenres) {
appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
}
processedSongs.sendWithTimeout(rawSong)
}
// Now that all songs are processed, also process albums and group them into their
// respective artists.
pruneMusicBrainzIdTree(albumGrouping) { old, new -> compareSongTracks(old, new) }
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
for (album in albums) {
for (rawArtist in album.rawArtists) {
appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
when (old) {
// Immediately replace any songs that initially held the priority position.
is SongImpl -> true
is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
}
}
// Artists and genres do not need to be grouped and can be processed immediately.
pruneMusicBrainzIdTree(artistGrouping) { old, new ->
when {
// Immediately replace any songs that initially held the priority position.
old is SongImpl && new is AlbumImpl -> true
old is AlbumImpl && new is SongImpl -> false
old is SongImpl && new is SongImpl -> {
compareSongDates(old, new)
}
old is AlbumImpl && new is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
}
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
music: N,
raw: R,
tree: MutableMap<String?, Grouping<R, O>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val body = tree[nameKey]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
}
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
tree: MutableMap<String?, Grouping<R, O>>,
map: (Grouping<R, O>) -> P
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
music: N,
raw: R,
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val musicBrainzIdGroups = tree[nameKey]
if (musicBrainzIdGroups != null) {
val body = musicBrainzIdGroups[raw.musicBrainzId]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
musicBrainzIdGroups[raw.musicBrainzId] =
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
} else {
// Need to initialize this grouping.
tree[nameKey] =
mutableMapOf(
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
}
}
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
prioritize: (old: M, new: M) -> Boolean
) {
for ((_, musicBrainzIdGroups) in tree) {
var nullGroup = musicBrainzIdGroups[null]
if (nullGroup == null) {
// Full MusicBrainz ID tagging. Nothing to do.
continue
}
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
// collapse all of them into the null group.
// TODO: More advanced heuristics eventually (tm)
musicBrainzIdGroups
.filter { it.key != null }
.forEach {
val (_, group) = it
nullGroup.music.addAll(group.music)
if (prioritize(group.raw.src, nullGroup.raw.src)) {
nullGroup.raw = group.raw
}
musicBrainzIdGroups.remove(it.key)
}
}
}
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
map: (Grouping<R, M>) -> T
): Set<T> {
val result = mutableSetOf<T>()
for ((_, musicBrainzIdGroups) in tree) {
for (group in musicBrainzIdGroups.values) {
result += map(group)
}
}
return result
}
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
new.track != null &&
(old.track == null ||
new.track < old.track ||
(new.track == old.track && new.name < old.name))
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
new.dates != null &&
(old.dates == null ||
new.dates < old.dates ||
(new.dates == old.dates && new.name < old.name))
private fun compareSongDates(old: SongImpl, new: SongImpl) =
new.date != null &&
(old.date == null ||
new.date < old.date ||
(new.date == old.date && new.name < old.name))
}
// TODO: Avoid redundant data creation
class DeviceLibraryImpl(
override val songs: Collection<SongImpl>,
override val albums: Collection<AlbumImpl>,
override val artists: Collection<ArtistImpl>,
override val genres: Collection<GenreImpl>
) : DeviceLibrary {
// Use a mapping to make finding information based on it's UID much faster.
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
private val albumUidMap = buildMap { albums.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()) } }
// All other music is built from songs, so comparison only needs to check songs.
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
override fun hashCode() = songs.hashCode()
override fun toString() =
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
"artists=${artists.size}, genres=${genres.size})"
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
override fun findSongByPath(path: Path) = songPathMap[path]
override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// song. Do what we can to hopefully find the song the user wanted to open.
val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName && it.size == size }
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2025 Auxio Project
* JClassRef.cpp is part of Auxio.
* Copyright (c) 2023 Auxio Project
* DeviceModule.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
@ -16,19 +16,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "JClassRef.h"
JClassRef::JClassRef(JNIEnv *env, const char *classpath) : env(env) {
clazz = env->FindClass(classpath);
}
package org.oxycblt.auxio.music.device
JClassRef::~JClassRef() {
env->DeleteLocalRef(clazz);
}
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
jmethodID JClassRef::method(const char *name, const char *signature) {
return env->GetMethodID(clazz, name, signature);
}
jclass& JClassRef::operator*() {
return clazz;
@Module
@InstallIn(SingletonComponent::class)
interface DeviceModule {
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl2): DeviceLibrary.Factory
@Binds fun interpreterFactory(factory: InterpreterFactoryImpl): Interpreter.Factory
}

View file

@ -0,0 +1,335 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceMusicImpl.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.music.device
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort
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.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.auxio.util.update
import kotlin.math.min
/**
* Library-backed implementation of [Song].
*
* @param linkedSong The completed [LinkedSong] all metadata van be inferred from
* @author Alexander Capehart (OxygenCobalt)
*/
class SongImpl(linkedSong: LinkedSong) : Song {
private val preSong = linkedSong.preSong
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preSong.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
?: Music.UID.auxio(MusicType.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
update(preSong.rawName)
update(preSong.preAlbum.rawName)
update(preSong.date)
update(preSong.track)
update(preSong.disc?.number)
update(preSong.preArtists.map { it.rawName })
update(preSong.preAlbum.preArtists.map { it.rawName })
}
override val name = preSong.name
override val track = preSong.track
override val disc = preSong.disc
override val date = preSong.date
override val uri = preSong.uri
override val cover = preSong.cover
override val path = preSong.path
override val mimeType = preSong.mimeType
override val size = preSong.size
override val durationMs = preSong.durationMs
override val replayGainAdjustment = preSong.replayGainAdjustment
override val dateAdded = preSong.dateAdded
override val album = linkedSong.album.resolve(this)
override val artists = linkedSong.artists.resolve(this)
override val genres = linkedSong.genres.resolve(this)
private val hashCode = 31 * uid.hashCode() + preSong.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is SongImpl &&
uid == other.uid &&
preSong == other.preSong
override fun toString() = "Song(uid=$uid, name=$name)"
}
/**
* Library-backed implementation of [Album].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
private val preAlbum = linkedAlbum.preAlbum
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
?: Music.UID.auxio(MusicType.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(preAlbum.rawName)
update(preAlbum.preArtists.map { it.rawName })
}
override val name = preAlbum.name
override val releaseType = preAlbum.releaseType
override var durationMs = 0L
override var dateAdded = 0L
override lateinit var cover: ParentCover
override var dates: Date.Range? = null
override val artists = linkedAlbum.artists.resolve(this)
override val songs = mutableSetOf<Song>()
private var hashCode = 31 * uid.hashCode() + preAlbum.hashCode()
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is AlbumImpl &&
uid == other.uid &&
preAlbum == other.preAlbum &&
songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
fun link(song: SongImpl) {
songs.add(song)
hashCode = 31 * hashCode + song.hashCode()
durationMs += song.durationMs
dateAdded = min(dateAdded, song.dateAdded)
if (song.date != null) {
dates = dates?.let {
if (song.date < it.min) Date.Range(song.date, it.max)
else if (song.date > it.max) Date.Range(it.min, song.date)
else it
} ?: Date.Range(song.date, song.date)
}
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album].
*/
fun finalize(): Album {
return this
}
}
/**
* Library-backed implementation of [Artist].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(
grouping: Grouping<RawArtist, Music>,
private val nameFactory: Name.Known.Factory
) : Artist {
private val rawArtist = grouping.raw.inner
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
override val name =
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
?: Name.Unknown(R.string.def_artist)
override val songs: Set<Song>
override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album>
override val durationMs: Long?
override val cover: ParentCover
override lateinit var genres: List<Genre>
private var hashCode = uid.hashCode()
init {
val distinctSongs = mutableSetOf<Song>()
val albumMap = mutableMapOf<Album, Boolean>()
for (music in grouping.music) {
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 in $name ${music::class.simpleName}")
}
}
songs = distinctSongs
val albums = albumMap.keys
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
val singleCover =
when (val src = grouping.raw.src) {
is SongImpl -> src.cover
is AlbumImpl -> src.cover.single
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
}
cover = ParentCover.from(singleCover, songs)
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is ArtistImpl &&
uid == other.uid &&
rawArtist == other.rawArtist &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist].
*/
fun finalize(): Artist {
// There are valid artist configurations:
// 1. No songs, no implicit albums, some explicit albums
// 2. Some songs, no implicit albums, some explicit albums
// 3. Some songs, some implicit albums, no implicit albums
// 4. Some songs, some implicit albums, some explicit albums
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
// but I can't be 100% certain.
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
"Malformed artist $name: Empty"
}
genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this
}
}
/**
* Library-backed implementation of [Genre].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImpl(
grouping: Grouping<RawGenre, SongImpl>,
private val nameFactory: Name.Known.Factory
) : Genre {
private val rawGenre = grouping.raw.inner
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
override val name =
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
?: Name.Unknown(R.string.def_genre)
override val songs: Set<Song>
override val artists: Set<Artist>
override val durationMs: Long
override val cover: ParentCover
private var hashCode = uid.hashCode()
init {
val distinctArtists = mutableSetOf<Artist>()
var totalDuration = 0L
for (song in grouping.music) {
song.link(this)
distinctArtists.addAll(song.artists)
totalDuration += song.durationMs
}
songs = grouping.music
artists = distinctArtists
durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl &&
uid == other.uid &&
rawGenre == other.rawGenre &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Genre {
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
return this
}
}

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